From 274b4f1e0694c7db912fd73b3be73f6fd6e09b2e Mon Sep 17 00:00:00 2001 From: Arno Date: Thu, 21 Feb 2019 22:14:56 +0100 Subject: [PATCH 01/18] Simple interface - a Client instance can create a tranfer (and add files on the go) - split Transfers' .new and .create, - new only initializes, - create also sends it to WeTransfers' Public API - need to have a seperate add_file method to make this work - MiniIO introduced to get wrap all things IO, in a very slim interface --- .ruby-version | 1 + lib/we_transfer/communication_helper.rb | 82 ++++++++ lib/we_transfer/mini_io.rb | 47 +++++ lib/we_transfer/transfer.rb | 66 ++++++ lib/we_transfer/we_transfer_file.rb | 23 ++ lib/we_transfer_client.rb | 126 +++++------ lib/we_transfer_client/boards.rb | 6 +- lib/we_transfer_client/future_board.rb | 6 +- lib/we_transfer_client/future_file.rb | 6 +- lib/we_transfer_client/future_link.rb | 6 +- lib/we_transfer_client/future_transfer.rb | 4 +- lib/we_transfer_client/remote_file.rb | 10 +- lib/we_transfer_client/transfers.rb | 8 +- spec/spec_helper.rb | 1 + spec/we_transfer/communication_helper_spec.rb | 60 ++++++ spec/we_transfer/mini_io_spec.rb | 50 +++++ spec/we_transfer/transfer_spec.rb | 197 ++++++++++++++++++ spec/we_transfer/we_transfer_file_spec.rb | 46 ++++ ...d_builder_spec.rb => board_builder_spe.rb} | 0 .../{boards_spec.rb => boards_spe.rb} | 0 ...ture_board_spec.rb => future_board_spe.rb} | 8 +- ...future_file_spec.rb => future_file_spe.rb} | 4 +- ...future_link_spec.rb => future_link_spe.rb} | 4 +- ...ransfer_spec.rb => future_transfer_spe.rb} | 6 +- ...mote_board_spec.rb => remote_board_spe.rb} | 0 ...remote_file_spec.rb => remote_file_spe.rb} | 0 ...remote_link_spec.rb => remote_link_spe.rb} | 0 ...ransfer_spec.rb => remote_transfer_spe.rb} | 0 ...uilder_spec.rb => transfer_builder_spe.rb} | 0 .../{transfers_spec.rb => transfers_spe.rb} | 0 spec/we_transfer_client_spec.rb | 79 +++---- wetransfer.gemspec | 9 +- 32 files changed, 707 insertions(+), 148 deletions(-) create mode 100644 .ruby-version create mode 100644 lib/we_transfer/communication_helper.rb create mode 100644 lib/we_transfer/mini_io.rb create mode 100644 lib/we_transfer/transfer.rb create mode 100644 lib/we_transfer/we_transfer_file.rb create mode 100644 spec/we_transfer/communication_helper_spec.rb create mode 100644 spec/we_transfer/mini_io_spec.rb create mode 100644 spec/we_transfer/transfer_spec.rb create mode 100644 spec/we_transfer/we_transfer_file_spec.rb rename spec/we_transfer_client/{board_builder_spec.rb => board_builder_spe.rb} (100%) rename spec/we_transfer_client/{boards_spec.rb => boards_spe.rb} (100%) rename spec/we_transfer_client/{future_board_spec.rb => future_board_spe.rb} (91%) rename spec/we_transfer_client/{future_file_spec.rb => future_file_spe.rb} (89%) rename spec/we_transfer_client/{future_link_spec.rb => future_link_spe.rb} (89%) rename spec/we_transfer_client/{future_transfer_spec.rb => future_transfer_spe.rb} (79%) rename spec/we_transfer_client/{remote_board_spec.rb => remote_board_spe.rb} (100%) rename spec/we_transfer_client/{remote_file_spec.rb => remote_file_spe.rb} (100%) rename spec/we_transfer_client/{remote_link_spec.rb => remote_link_spe.rb} (100%) rename spec/we_transfer_client/{remote_transfer_spec.rb => remote_transfer_spe.rb} (100%) rename spec/we_transfer_client/{transfer_builder_spec.rb => transfer_builder_spe.rb} (100%) rename spec/we_transfer_client/{transfers_spec.rb => transfers_spe.rb} (100%) diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..6a6a3d8 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.6.1 diff --git a/lib/we_transfer/communication_helper.rb b/lib/we_transfer/communication_helper.rb new file mode 100644 index 0000000..13fa876 --- /dev/null +++ b/lib/we_transfer/communication_helper.rb @@ -0,0 +1,82 @@ +module WeTransfer + class CommunicationError < StandardError; end + module CommunicationHelper + API_URI_BASE = 'https://dev.wetransfer.com'.freeze + + class << self + attr_accessor :logger, :api_key + end + + def logger + @logger ||= CommunicationHelper.logger + end + + def api_key + @api_key ||= CommunicationHelper.api_key + end + + def request_as + authorize_if_no_bearer_token! + + @request_as ||= Faraday.new(API_URI_BASE) do |c| + c.response :logger, logger + c.adapter Faraday.default_adapter + c.headers = auth_headers.merge( + "User-Agent" => "WetransferRubySdk/#{WeTransfer::VERSION} Ruby #{RUBY_VERSION}", + "Content-Type" => "application/json", + ) + end + end + + private + + def auth_headers + authorize_if_no_bearer_token! + + { + 'X-API-Key' => api_key, + 'Authorization' => ('Bearer %s' % @bearer_token), + } + end + + def ensure_ok_status!(response) + case response.status + when 200..299 + true + when 400..499 + logger.error response + raise WeTransfer::CommunicationError, JSON.parse(response.body)["message"] + when 500..504 + logger.error response + raise WeTransfer::CommunicationError, "Response had a #{response.status} code, we could retry" + else + logger.error response + raise WeTransfer::CommunicationError, "Response had a #{response.status} code, no idea what to do with that" + end + end + + def authorize_if_no_bearer_token! + return @bearer_token if @bearer_token + + response = Faraday.new(API_URI_BASE) do |c| + c.response :logger, logger + c.adapter Faraday.default_adapter + c.headers = { + "User-Agent" => "WetransferRubySdk/#{WeTransfer::VERSION} Ruby #{RUBY_VERSION}", + "Content-Type" => "application/json", + } + end.post( + '/v2/authorize', + '', + 'Content-Type' => 'application/json', + 'X-API-Key' => CommunicationHelper.api_key, + ) + ensure_ok_status!(response) + bearer_token = JSON.parse(response.body)['token'] + if bearer_token.nil? || bearer_token.empty? + raise WeTransfer::CommunicationError, "The authorization call returned #{response.body} and no usable :token key could be found there" + end + @bearer_token = bearer_token + end + end +end diff --git a/lib/we_transfer/mini_io.rb b/lib/we_transfer/mini_io.rb new file mode 100644 index 0000000..abf7d08 --- /dev/null +++ b/lib/we_transfer/mini_io.rb @@ -0,0 +1,47 @@ +module WeTransfer + # Wrapper around an IO object, delegating only methods we need for creating + # and sending a file in chunks. + # + class MiniIO + # Initialize with an io object to wrap + # + # @param io [anything] An object that responds to #read, #rewind, #seek, #size + # + def initialize(io) + @io = io + end + + def read(*args) + @io.read(*args) + end + + def rewind + @io.rewind + end + + def seek(*args) + @io.seek(*args) + end + + # The size delegated to io. + # If io is nil, we return nil. + # + # nil is fine, since this method is used only as the default size for a + # WeTransferFile + # @returns [Integer, nil] the size of the io. See IO#size + # + def size + @io&.size + end + + # The name of the io, guessed using File.basename. If this raises a TypeError + # we swallow the error, since this is used only as the default name for a + # WeTransferFile + # + def name + File.basename(@io) + rescue TypeError + # yeah, what? + end + end +end diff --git a/lib/we_transfer/transfer.rb b/lib/we_transfer/transfer.rb new file mode 100644 index 0000000..2640b88 --- /dev/null +++ b/lib/we_transfer/transfer.rb @@ -0,0 +1,66 @@ +module WeTransfer + class Transfer + class DuplicateFileNameError < ArgumentError; end + class NoFilesAddedError < StandardError; end + + include CommunicationHelper + + def self.create(message:, &block) + transfer = new(message: message) + + transfer.persist(&block) + end + + def initialize(message:) + @message = message + @files = [] + @unique_file_names = Set.new + end + + # Add files (if still needed) + def persist + yield(self) if block_given? + + create_remote_transfer + + ## files should now be in persisted status + + end + + # Add one or more files to a transfer, so a transfer can be created over the + # WeTransfer public API + # + # @param name [String] (nil) the name of the file + # + # @return [WeTransfer::Client] + def add_file(name: nil, size: nil, io: nil) + file = WeTransferFile.new(name: name, size: size, io: io) + raise DuplicateFileNameError unless @unique_file_names.add?(file.name.downcase) + + @files << file + self + end + + private + + def as_json_request_params + { + message: @message, + files: @files.map(&:as_json_request_params), + } + end + + def create_remote_transfer + raise NoFilesAddedError if @unique_file_names.empty? + + response = request_as.post( + '/v2/transfers', + as_json_request_params.to_json, + {} + ) + ensure_ok_status!(response) + + @remote_transfer = RemoteTransfer.new(JSON.parse(response.body, symbolize_names: true)) + end + end +end diff --git a/lib/we_transfer/we_transfer_file.rb b/lib/we_transfer/we_transfer_file.rb new file mode 100644 index 0000000..666ac36 --- /dev/null +++ b/lib/we_transfer/we_transfer_file.rb @@ -0,0 +1,23 @@ +module WeTransfer + class WeTransferFile + + attr_reader :name + + def initialize(name: nil, size: nil, io: nil) + @io = io.is_a?(MiniIO) ? io : MiniIO.new(io) + @name = name || @io.name + @size = size || @io.size + + raise ArgumentError, "Need a file name and a size, or io should provide it" unless @name && @size + end + + def as_json_request_params + { + name: @name, + size: @size, + } + end + + # def persist() + end +end diff --git a/lib/we_transfer_client.rb b/lib/we_transfer_client.rb index b9b8371..4e5d70e 100644 --- a/lib/we_transfer_client.rb +++ b/lib/we_transfer_client.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'faraday' require 'logger' require 'json' @@ -16,93 +18,71 @@ require_relative 'we_transfer_client/transfers' require_relative 'we_transfer_client/boards' +%w[communication_helper transfer mini_io we_transfer_file ].each do |file| + require_relative "we_transfer/#{file}" +end + module WeTransfer class Client - include WeTransfer::Client::Transfers - include WeTransfer::Client::Boards + include CommunicationHelper - class Error < StandardError - end + class Error < StandardError; end + NullLogger = Logger.new(nil) - NULL_LOGGER = Logger.new(nil) + API_URL_BASE = 'https://dev.wetransfer.com' - def initialize(api_key:, logger: NULL_LOGGER) - @api_url_base = 'https://dev.wetransfer.com' - @api_key = api_key.to_str + # include WeTransfer::Client::Transfers + # include WeTransfer::Client::Boards + + ## initialize a WeTransfer::Client + # + # @param api_key [String] The API key you want to authenticate with + # @param logger [Logger] (NullLogger) your custom logger + # + # @return [WeTransfer::Client] + def initialize(api_key:, logger: NullLogger) + CommunicationHelper.api_key = api_key @bearer_token = nil @logger = logger + CommunicationHelper.logger = logger end - def upload_file(object:, file:, io:) - put_io_in_parts(object: object, file: file, io: io) - end - - def complete_file!(object:, file:) - object.prepare_file_completion(client: self, file: file) - end + def create_transfer(**args, &block) + transfer = WeTransfer::Transfer.new(args, &block) + @transfer = transfer - def check_for_file_duplicates(files, new_file) - if files.select { |file| file.name == new_file.name }.size != 1 - raise ArgumentError, 'Duplicate file entry' - end + # TODO: Either we have an accessor for transfer, or we're not returning self - the transfer is unavailable otherwise + self end - def put_io_in_parts(object:, file:, io:) - (1..file.multipart.part_numbers).each do |part_n_one_based| - upload_url, chunk_size = object.prepare_file_upload(client: self, file: file, part_number: part_n_one_based) - part_io = StringIO.new(io.read(chunk_size)) - part_io.rewind - response = faraday.put( - upload_url, - part_io, - 'Content-Type' => 'binary/octet-stream', - 'Content-Length' => part_io.size.to_s - ) - ensure_ok_status!(response) - end - {success: true, message: 'File Uploaded'} - end + # def upload_file(object:, file:, io:) + # put_io_in_parts(object: object, file: file, io: io) + # end - def faraday - Faraday.new(@api_url_base) do |c| - c.response :logger, @logger - c.adapter Faraday.default_adapter - c.headers = { 'User-Agent' => "WetransferRubySdk/#{WeTransfer::VERSION} Ruby #{RUBY_VERSION}"} - end - end + # def complete_file!(object:, file:) + # object.prepare_file_completion(client: self, file: file) + # end - def authorize_if_no_bearer_token! - return if @bearer_token - response = faraday.post('/v2/authorize', '{}', 'Content-Type' => 'application/json', 'X-API-Key' => @api_key) - ensure_ok_status!(response) - @bearer_token = JSON.parse(response.body, symbolize_names: true)[:token] - if @bearer_token.nil? || @bearer_token.empty? - raise Error, "The authorization call returned #{response.body} and no usable :token key could be found there" - end - end + # def check_for_file_duplicates(files, new_file) + # if files.select { |file| file.name == new_file.name }.size != 1 + # raise ArgumentError, 'Duplicate file entry' + # end + # end - def auth_headers - raise 'No bearer token retrieved yet' unless @bearer_token - { - 'X-API-Key' => @api_key, - 'Authorization' => ('Bearer %s' % @bearer_token), - } - end - - def ensure_ok_status!(response) - case response.status - when 200..299 - true - when 400..499 - @logger.error response - raise Error, "Response had a #{response.status} code, the server will not accept this request even if retried" - when 500..504 - @logger.error response - raise Error, "Response had a #{response.status} code, we could retry" - else - @logger.error response - raise Error, "Response had a #{response.status} code, no idea what to do with that" - end - end + # def put_io_in_parts(object:, file:, io:) + # (1..file.multipart.part_numbers).each do |part_n_one_based| + # upload_url, chunk_size = object.prepare_file_upload(client: self, file: file, part_number: part_n_one_based) + # part_io = StringIO.new(io.read(chunk_size)) + # part_io.rewind + # response = request_as.put( + # upload_url, + # part_io, + # 'Content-Type' => 'binary/octet-stream', + # 'Content-Length' => part_io.size.to_s + # ) + # ensure_ok_status!(response) + # end + # {success: true, message: 'File Uploaded'} + # end end end diff --git a/lib/we_transfer_client/boards.rb b/lib/we_transfer_client/boards.rb index 3444eef..7669bb4 100644 --- a/lib/we_transfer_client/boards.rb +++ b/lib/we_transfer_client/boards.rb @@ -40,9 +40,9 @@ def create_feature_board(name:, description:, future_board_class: FutureBoard, b def create_remote_board(board:, remote_board_class: RemoteBoard) authorize_if_no_bearer_token! - response = faraday.post( + response = request_as.post( '/v2/boards', - JSON.pretty_generate(board.to_initial_request_params), + JSON.generate(board.to_initial_request_params), auth_headers.merge('Content-Type' => 'application/json') ) ensure_ok_status!(response) @@ -61,7 +61,7 @@ def add_items_to_remote_board(items:, remote_board:) def request_board(board:, remote_board_class: RemoteBoard) authorize_if_no_bearer_token! - response = faraday.get( + response = request_as.get( "/v2/boards/#{board.id}", {}, auth_headers.merge('Content-Type' => 'application/json') diff --git a/lib/we_transfer_client/future_board.rb b/lib/we_transfer_client/future_board.rb index 15f2042..cfaf6fc 100644 --- a/lib/we_transfer_client/future_board.rb +++ b/lib/we_transfer_client/future_board.rb @@ -22,11 +22,11 @@ def to_initial_request_params } end - def to_request_params + def as_json_request_params { name: name, description: description, - items: items.map(&:to_request_params), - } + items: items.map(&:as_json_request_params), + }.to_json end end diff --git a/lib/we_transfer_client/future_file.rb b/lib/we_transfer_client/future_file.rb index f98780d..81ea70d 100644 --- a/lib/we_transfer_client/future_file.rb +++ b/lib/we_transfer_client/future_file.rb @@ -6,7 +6,7 @@ def initialize(name:, io:) @io = io end - def to_request_params + def as_json_request_params { name: @name, size: @io.size, @@ -15,10 +15,10 @@ def to_request_params def add_to_board(client:, remote_board:) client.authorize_if_no_bearer_token! - response = client.faraday.post( + response = client.request_as.post( "/v2/boards/#{remote_board.id}/files", # this needs to be a array with hashes => [{name, filesize}] - JSON.pretty_generate([to_request_params]), + JSON.generate([as_json_request_params]), client.auth_headers.merge('Content-Type' => 'application/json') ) client.ensure_ok_status!(response) diff --git a/lib/we_transfer_client/future_link.rb b/lib/we_transfer_client/future_link.rb index b158bc4..dd95c71 100644 --- a/lib/we_transfer_client/future_link.rb +++ b/lib/we_transfer_client/future_link.rb @@ -6,7 +6,7 @@ def initialize(url:, title: url) @title = title end - def to_request_params + def as_json_request_params { url: url, title: title, @@ -15,10 +15,10 @@ def to_request_params def add_to_board(client:, remote_board:) client.authorize_if_no_bearer_token! - response = client.faraday.post( + response = client.request_as.post( "/v2/boards/#{remote_board.id}/links", # this needs to be a array with hashes => [{name, filesize}] - JSON.pretty_generate([to_request_params]), + JSON.generate([as_json_request_params]), client.auth_headers.merge('Content-Type' => 'application/json') ) client.ensure_ok_status!(response) diff --git a/lib/we_transfer_client/future_transfer.rb b/lib/we_transfer_client/future_transfer.rb index 37a1c75..0892375 100644 --- a/lib/we_transfer_client/future_transfer.rb +++ b/lib/we_transfer_client/future_transfer.rb @@ -6,10 +6,10 @@ def initialize(message:, files: []) @files = files end - def to_request_params + def as_json_request_params { message: message, - files: files.map(&:to_request_params), + files: files.map(&:as_json_request_params), } end end diff --git a/lib/we_transfer_client/remote_file.rb b/lib/we_transfer_client/remote_file.rb index ce69c9e..c4d7829 100644 --- a/lib/we_transfer_client/remote_file.rb +++ b/lib/we_transfer_client/remote_file.rb @@ -13,7 +13,7 @@ def initialize(id:, name:, size:, url: nil, type: 'file', multipart:) end def request_transfer_upload_url(client:, transfer_id:, part_number:) - response = client.faraday.get( + response = client.request_as.get( "/v2/transfers/#{transfer_id}/files/#{@id}/upload-url/#{part_number}", {}, client.auth_headers.merge('Content-Type' => 'application/json') @@ -23,7 +23,7 @@ def request_transfer_upload_url(client:, transfer_id:, part_number:) end def request_board_upload_url(client:, board_id:, part_number:) - response = client.faraday.get( + response = client.request_as.get( "/v2/boards/#{board_id}/files/#{@id}/upload-url/#{part_number}/#{@multipart.id}", {}, client.auth_headers.merge('Content-Type' => 'application/json') @@ -34,9 +34,9 @@ def request_board_upload_url(client:, board_id:, part_number:) def complete_transfer_file(client:, transfer_id:) body = {part_numbers: @multipart.part_numbers} - response = client.faraday.put( + response = client.request_as.put( "/v2/transfers/#{transfer_id}/files/#{@id}/upload-complete", - JSON.pretty_generate(body), + JSON.generate(body), client.auth_headers.merge('Content-Type' => 'application/json') ) client.ensure_ok_status!(response) @@ -44,7 +44,7 @@ def complete_transfer_file(client:, transfer_id:) end def complete_board_file(client:, board_id:) - response = client.faraday.put( + response = client.request_as.put( "/v2/boards/#{board_id}/files/#{@id}/upload-complete", '{}', client.auth_headers.merge('Content-Type' => 'application/json') diff --git a/lib/we_transfer_client/transfers.rb b/lib/we_transfer_client/transfers.rb index 662e357..2105128 100644 --- a/lib/we_transfer_client/transfers.rb +++ b/lib/we_transfer_client/transfers.rb @@ -38,9 +38,9 @@ def create_future_transfer(message:, future_transfer_class: FutureTransfer, tran def create_remote_transfer(xfer) authorize_if_no_bearer_token! - response = faraday.post( + response = request_as.post( '/v2/transfers', - JSON.pretty_generate(xfer.to_request_params), + JSON.generate(xfer.as_json_request_params), auth_headers.merge('Content-Type' => 'application/json') ) ensure_ok_status!(response) @@ -49,7 +49,7 @@ def create_remote_transfer(xfer) def complete_transfer_call(object) authorize_if_no_bearer_token! - response = faraday.put( + response = request_as.put( "/v2/transfers/#{object.id}/finalize", '', auth_headers.merge('Content-Type' => 'application/json') @@ -60,7 +60,7 @@ def complete_transfer_call(object) def request_transfer(transfer_id) authorize_if_no_bearer_token! - response = faraday.get( + response = request_as.get( "/v2/transfers/#{transfer_id}", {}, auth_headers.merge('Content-Type' => 'application/json') diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f9c2660..ee5e8bd 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -12,6 +12,7 @@ Bundler.setup require 'tempfile' require 'dotenv' +require 'webmock/rspec' Dotenv.load module SpecHelpers diff --git a/spec/we_transfer/communication_helper_spec.rb b/spec/we_transfer/communication_helper_spec.rb new file mode 100644 index 0000000..365bf95 --- /dev/null +++ b/spec/we_transfer/communication_helper_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +class CommHelper + include WeTransfer::CommunicationHelper + public :ensure_ok_status! +end + +describe WeTransfer::CommunicationHelper do + context "#ensure_ok_status!" do + subject { CommHelper.new } + + before(:all) do + Response = Struct.new(:status, :body) + WeTransfer::CommunicationHelper.logger = Logger.new(nil) + end + + context "on success" do + it "returns true if the status code is in the 2xx range" do + (200..299).each do |status_code| + response = Response.new(status_code) + expect(subject.ensure_ok_status!(response)).to be_truthy + end + end + end + + context "unsuccessful" do + it "raises with a message including the status code the server returned" do + response = Response.new("404") + expect { subject.ensure_ok_status!(response) } + .to raise_error(WeTransfer::CommunicationError, %r/Response had a 404 code/) + + response = Response.new("Meh") + expect { subject.ensure_ok_status!(response) } + .to raise_error(WeTransfer::CommunicationError, %r/Response had a Meh code/) + end + + it "if there is a server error, it raises with information that we can retry" do + (500..504).each do |status_code| + response = Response.new(status_code) + expect { subject.ensure_ok_status!(response) } + .to raise_error(WeTransfer::CommunicationError, /we could retry/) + end + end + + it "on client error, it raises with information that the server responded" do + (400..499).each do |status_code| + response = Response.new(status_code, { "message" => 'this is a test' }.to_json) + expect { subject.ensure_ok_status!(response) } + .to raise_error(WeTransfer::CommunicationError, 'this is a test') + end + end + + it "if the status code is unknown, it raises a generic error" do + response = Response.new("I'm no status code") + expect { subject.ensure_ok_status!(response) } + .to raise_error(WeTransfer::CommunicationError, /no idea what to do/) + end + end + end +end diff --git a/spec/we_transfer/mini_io_spec.rb b/spec/we_transfer/mini_io_spec.rb new file mode 100644 index 0000000..da69ebe --- /dev/null +++ b/spec/we_transfer/mini_io_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +describe WeTransfer::MiniIO do + subject(:mini_io) { described_class.new(io_spy) } + let(:io_spy) { instance_double(StringIO) } + + context "wraps the io" do + it "delegates #read" do + expect(io_spy) + .to receive(:read) + .with(:foo) + + mini_io.read(:foo) + end + + it "delegates #seek" do + expect(io_spy) + .to receive(:seek) + .with(:bar) + + mini_io.seek(:bar) + end + + it "delegates #rewind" do + expect(io_spy) + .to receive(:rewind) + .with(no_args) + + mini_io.rewind + end + + it "delegates #size" do + expect(io_spy) + .to receive(:size) + .with(no_args) + + mini_io.size + end + end + + describe "#name" do + it "guesses a name using File.basename" do + expect(File) + .to receive(:basename) + .with(io_spy) + + mini_io.name + end + end +end diff --git a/spec/we_transfer/transfer_spec.rb b/spec/we_transfer/transfer_spec.rb new file mode 100644 index 0000000..cca5bc8 --- /dev/null +++ b/spec/we_transfer/transfer_spec.rb @@ -0,0 +1,197 @@ +require 'spec_helper' + +describe WeTransfer::Transfer do + let!(:authentication_stub) { + stub_request(:post, "#{WeTransfer::CommunicationHelper::API_URI_BASE}/v2/authorize") + .to_return(status: 200, body: {token: 'fake-test-token'}.to_json, headers: {}) + } + + let!(:create_transfer_stub) do + stub_request(:post, "#{described_class::API_URI_BASE}/v2/transfers"). + to_return( + status: 200, + body: { + success: true, + id: "24cd3f4ccf15232e5660052a3688c03f20190221200022", + state: "uploading", + message: "test transfer", + url: nil, + files: [ + { + id: "f740463d4f995720280fa08efe1911ef20190221200022", + name: "test_file", + size: 8, + multipart: { + part_numbers: 1, + chunk_size: 8 + }, + type: "file", + }, + ], + expires_at: "2019-02-28T20:00:22Z" + }.to_json, + headers: {}, + ) + end + + describe ".create" do + it "instantiates a transfer" do + expect(described_class) + .to receive(:new) + .with(message: 'through .create') + .and_call_original + + begin + described_class.create(message: 'through .create') + rescue WeTransfer::Transfer::NoFilesAddedError + end + end + + it "calls #persist on the instantiated transfer" do + add_file_lambda = ->(transfer) { transfer.add_file(name: 'foo', size: 8) } + + expect_any_instance_of(described_class) + .to receive(:persist) do |*_args, &block| + expect(add_file_lambda).to be(block) + end + .and_call_original + + described_class.create(message: 'through .create', &add_file_lambda) + end + end + + describe "initialize" do + it "passes with a message" do + expect { described_class.new(message: 'test transfer') } + .to_not raise_error + end + + it "fails without a message" do + expect { described_class.new } + .to raise_error ArgumentError, %r|message| + end + end + + describe "#persist" do + context "adding files, using a block" do + it "works on a transfer without any files" do + transfer = described_class.new(message: "test transfer") + + transfer.persist { |transfer| transfer.add_file(name: 'test_file', size: 8) } + + expect(create_transfer_stub) + .to have_been_requested + end + + it "works on a transfer that already has some files" do + transfer = described_class.new(message: 'test transfer') do |t| + t.add_file(name: 'file1', size: 8) + end + + transfer.persist { |transfer| transfer.add_file(name: 'file2', size: 8) } + + expect(create_transfer_stub) + .to have_been_requested + end + end + + context "not adding files, using a block" do + it "can create a remote transfer if the transfer already had one" do + transfer = described_class.new(message: 'test transfer') + transfer.add_file(name: 'file1', size: 8) + + transfer.persist + + expect(create_transfer_stub) + .to have_been_requested + end + end + + context "with files" do + it "can create a remote transfer if the transfer " + end + context "with files" do + it "creates a remote transfer" + it "exposes new methods on the WeTransferFile instances" + end + end + + describe "#add_file" do + subject(:transfer) do + described_class.new(message: 'test transfer') do |transfer| + # this block is used to add files using + # transfer.add_file(name:, size:, io:) + end + end + + let(:file_params) { { name: :foo, size: :bar, io: :baz } } + + it "can be called with only a name and a size" do + expect { transfer.add_file(name: :foo, size: :bar) } + .to_not raise_error + end + + context "when called with only an io" do + it "does work if the io provides a name and a size" do + transfer.add_file(io: File.open('Gemfile')) + end + + it "doesn't work if the io has no name" do + nameless_io = StringIO.new('I have no name, but a size I have') + expect { transfer.add_file(io: nameless_io) } + .to raise_error(ArgumentError, %r|name.*size.* or io should provide it|) + end + end + + it "can be called with an io, a name and a size" do + io = File.open('Gemfile') + expect { transfer.add_file(name: 'Gemfile', size: 20, io: io) } + .to_not raise_error + end + + it "does not accept duplicate file names, case insensitive" do + file_a_params = { name: 'same', size: 416 } + file_b_params = { name: 'same', size: 501 } + file_c_params = { name: 'SAME', size: 816 } + + transfer.add_file(file_a_params) + + expect { transfer.add_file(file_b_params) } + .to raise_error(WeTransfer::Transfer::DuplicateFileNameError) + + expect { transfer.add_file(file_c_params) } + .to raise_error(WeTransfer::Transfer::DuplicateFileNameError) + end + + context "WeTransferFile interaction" do + let(:fake_transfer_file) { instance_double(WeTransfer::WeTransferFile, name: 'fake') } + + it "instantiates a TransferFile" do + expect(WeTransfer::WeTransferFile) + .to receive(:new) + .and_return(fake_transfer_file) + + transfer.add_file(file_params) + end + + it "adds the instantiated TransferFile to the @files collection" do + allow(WeTransfer::WeTransferFile) + .to receive(:new) + .and_return(fake_transfer_file) + + transfer.add_file(file_params) + + expect(subject.instance_variable_get(:@files)) + .to eq [fake_transfer_file] + end + + it "returns self" do + allow(WeTransfer::WeTransferFile) + .to receive(:new) + .and_return(fake_transfer_file) + + expect(transfer.add_file(file_params)).to eq transfer + end + end + end +end diff --git a/spec/we_transfer/we_transfer_file_spec.rb b/spec/we_transfer/we_transfer_file_spec.rb new file mode 100644 index 0000000..a2c054f --- /dev/null +++ b/spec/we_transfer/we_transfer_file_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe WeTransfer::WeTransferFile do + describe "initializer" do + it "works with an IO that provides a name" + it "works with an IO and a name" + it "works with a name and a size" + + it "raises if name is unavailable" do + nameless_io = StringIO.new('I have no name, but a size I have') + expect { described_class.new(io: nameless_io) } + .to raise_error(ArgumentError) + + expect { described_class.new(size: 10) } + .to raise_error(ArgumentError) + end + + it "raises if size is unavailable" do + expect { described_class.new(name: 'README') } + .to raise_error(ArgumentError) + end + + it "infers the name using File.basename, with the io as its arg" do + io = File.open('Gemfile') + + expect(File) + .to receive(:basename) + .with(io) + .and_return "delegated" + + described_class.new(io: io) + end + + it "delegates the size to io if unavailable" do + io = File.open('Gemfile') + + expect(File) + .to receive(:basename) + .with(io) + .and_return "delegated" + + described_class.new(io: io) + end + it "wraps the IO in a MiniIO" + end +end diff --git a/spec/we_transfer_client/board_builder_spec.rb b/spec/we_transfer_client/board_builder_spe.rb similarity index 100% rename from spec/we_transfer_client/board_builder_spec.rb rename to spec/we_transfer_client/board_builder_spe.rb diff --git a/spec/we_transfer_client/boards_spec.rb b/spec/we_transfer_client/boards_spe.rb similarity index 100% rename from spec/we_transfer_client/boards_spec.rb rename to spec/we_transfer_client/boards_spe.rb diff --git a/spec/we_transfer_client/future_board_spec.rb b/spec/we_transfer_client/future_board_spe.rb similarity index 91% rename from spec/we_transfer_client/future_board_spec.rb rename to spec/we_transfer_client/future_board_spe.rb index d6371a3..c4902c4 100644 --- a/spec/we_transfer_client/future_board_spec.rb +++ b/spec/we_transfer_client/future_board_spe.rb @@ -33,21 +33,21 @@ end end - describe '#to_request_params' do + describe '#as_json_request_params' do it 'has a name' do - as_params = described_class.new(params).to_request_params + as_params = described_class.new(params).as_json_request_params expect(as_params[:name]).to be_kind_of(String) end it 'has a description' do - as_params = described_class.new(params).to_request_params + as_params = described_class.new(params).as_json_request_params expect(as_params[:description]).to be(params[:description]) end it 'has items' do file = FutureFile.new(name: 'yes', io: File.open(__FILE__, 'rb')) params[:items] << file - as_params = described_class.new(params).to_request_params + as_params = described_class.new(params).as_json_request_params expect(as_params[:items].count).to be(1) end end diff --git a/spec/we_transfer_client/future_file_spec.rb b/spec/we_transfer_client/future_file_spe.rb similarity index 89% rename from spec/we_transfer_client/future_file_spec.rb rename to spec/we_transfer_client/future_file_spe.rb index 5bac7b5..d7faa7a 100644 --- a/spec/we_transfer_client/future_file_spec.rb +++ b/spec/we_transfer_client/future_file_spe.rb @@ -25,9 +25,9 @@ end end - describe '#to_request_params' do + describe '#as_json_request_params' do it 'returns a hash with name and size' do - as_params = described_class.new(params).to_request_params + as_params = described_class.new(params).as_json_request_params expect(as_params[:name]).to eq('yes') expect(as_params[:size]).to be_kind_of(Integer) diff --git a/spec/we_transfer_client/future_link_spec.rb b/spec/we_transfer_client/future_link_spe.rb similarity index 89% rename from spec/we_transfer_client/future_link_spec.rb rename to spec/we_transfer_client/future_link_spe.rb index 173fbe8..af385c2 100644 --- a/spec/we_transfer_client/future_link_spec.rb +++ b/spec/we_transfer_client/future_link_spe.rb @@ -21,9 +21,9 @@ end end - describe '#to_request_params' do + describe '#as_json_request_params' do it 'creates params properly' do - as_params = described_class.new(params).to_request_params + as_params = described_class.new(params).as_json_request_params expect(as_params[:url]).to eq('http://www.wetransfer.com') expect(as_params[:title]).to be_kind_of(String) diff --git a/spec/we_transfer_client/future_transfer_spec.rb b/spec/we_transfer_client/future_transfer_spe.rb similarity index 79% rename from spec/we_transfer_client/future_transfer_spec.rb rename to spec/we_transfer_client/future_transfer_spe.rb index 51d4437..e3b5962 100644 --- a/spec/we_transfer_client/future_transfer_spec.rb +++ b/spec/we_transfer_client/future_transfer_spe.rb @@ -25,15 +25,15 @@ end end - describe 'to_request_params' do + describe 'as_json_request_params' do it 'includes the message' do new_transfer = transfer.new(message: 'test') - expect(new_transfer.to_request_params[:message]).to be(new_transfer.message) + expect(new_transfer.as_json_request_params[:message]).to be(new_transfer.message) end it 'includes the files as an array' do new_transfer = transfer.new(message: 'test') - expect(new_transfer.to_request_params[:files]).to eq([]) + expect(new_transfer.as_json_request_params[:files]).to eq([]) end end end diff --git a/spec/we_transfer_client/remote_board_spec.rb b/spec/we_transfer_client/remote_board_spe.rb similarity index 100% rename from spec/we_transfer_client/remote_board_spec.rb rename to spec/we_transfer_client/remote_board_spe.rb diff --git a/spec/we_transfer_client/remote_file_spec.rb b/spec/we_transfer_client/remote_file_spe.rb similarity index 100% rename from spec/we_transfer_client/remote_file_spec.rb rename to spec/we_transfer_client/remote_file_spe.rb diff --git a/spec/we_transfer_client/remote_link_spec.rb b/spec/we_transfer_client/remote_link_spe.rb similarity index 100% rename from spec/we_transfer_client/remote_link_spec.rb rename to spec/we_transfer_client/remote_link_spe.rb diff --git a/spec/we_transfer_client/remote_transfer_spec.rb b/spec/we_transfer_client/remote_transfer_spe.rb similarity index 100% rename from spec/we_transfer_client/remote_transfer_spec.rb rename to spec/we_transfer_client/remote_transfer_spe.rb diff --git a/spec/we_transfer_client/transfer_builder_spec.rb b/spec/we_transfer_client/transfer_builder_spe.rb similarity index 100% rename from spec/we_transfer_client/transfer_builder_spec.rb rename to spec/we_transfer_client/transfer_builder_spe.rb diff --git a/spec/we_transfer_client/transfers_spec.rb b/spec/we_transfer_client/transfers_spe.rb similarity index 100% rename from spec/we_transfer_client/transfers_spec.rb rename to spec/we_transfer_client/transfers_spe.rb diff --git a/spec/we_transfer_client_spec.rb b/spec/we_transfer_client_spec.rb index 1955098..26c4e51 100644 --- a/spec/we_transfer_client_spec.rb +++ b/spec/we_transfer_client_spec.rb @@ -8,50 +8,55 @@ expect(WeTransfer::VERSION).to be_kind_of(String) end - describe "#ensure_ok_status!" do - before(:all) { Response = Struct.new(:status) } - - context "on success" do - it "returns true if the status code is in the 2xx range" do - (200..299).each do |status_code| - response = Response.new(status_code) - expect(subject.ensure_ok_status!(response)).to be_truthy - end - end + describe "#create_transfer" do + let(:transfer) { instance_double(WeTransfer::Transfer) } + + it "raises an ArgumentError without the :message keyword param" do + expect { subject.create_transfer }.to raise_error(ArgumentError, %r/message/) end - context "unsuccessful" do - it "raises with a message including the status code the server returned" do - response = Response.new("404") - expect { subject.ensure_ok_status!(response) } - .to raise_error(WeTransfer::Client::Error, %r/Response had a 404 code/) + it "instantiates a Transfer" do + expect(WeTransfer::Transfer) + .to receive(:new) + .with(message: 'fake transfer') + .and_return(transfer) - response = Response.new("Mehh") - expect { subject.ensure_ok_status!(response) } - .to raise_error(WeTransfer::Client::Error, %r/Response had a Mehh code/) - end + subject.create_transfer(message: 'fake transfer') + end - it "if there is a server error, it raises with information that we can retry" do - (500..504).each do |status_code| - response = Response.new(status_code) - expect { subject.ensure_ok_status!(response) } - .to raise_error(WeTransfer::Client::Error, /we could retry/) - end - end + it "stores the transfer in @transfer" do + allow(WeTransfer::Transfer) + .to receive(:new) + .and_return(transfer) + subject.create_transfer(message: 'foo') - it "on client error, it raises with information that the server cannot understand this" do - (400..499).each do |status_code| - response = Response.new(status_code) - expect { subject.ensure_ok_status!(response) } - .to raise_error(WeTransfer::Client::Error, /server will not accept this request even if retried/) - end - end + expect(subject.instance_variable_get(:@transfer)).to eq transfer + end + + it "accepts a block, that is passed to the Transfer instance" do + allow(WeTransfer::Transfer) + .to receive(:new) { |&transfer| transfer.call(name: 'meh') } + .with(message: "foo") + .and_return(transfer) + + expect { |probe| subject.create_transfer(message: 'foo', &probe) }.to yield_with_args(name: 'meh') + end + + it "returns self" do + allow(WeTransfer::Transfer) + .to receive(:new) + .and_return(transfer) + expect(subject.create_transfer(message: 'foo')).to eq subject + end + end - it "if the status code is unknown, it raises a generic error" do - response = Response.new("I aint a status code") - expect { subject.ensure_ok_status!(response) } - .to raise_error(WeTransfer::Client::Error, /no idea what to do/) + describe "integrations" do + it "works" do + client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) + transfer = client.create_transfer(message: 'test transfer') do |transfer| + transfer.add_file(name: 'test_file', size: 30) end + # binding.pry end end end diff --git a/wetransfer.gemspec b/wetransfer.gemspec index 626ebaa..5afff79 100644 --- a/wetransfer.gemspec +++ b/wetransfer.gemspec @@ -30,15 +30,16 @@ Gem::Specification.new do |spec| spec.add_dependency 'faraday', '~> 0.12' - spec.add_development_dependency 'dotenv', '~> 2.2' spec.add_development_dependency 'bundler', '~> 1.16' + spec.add_development_dependency 'dotenv', '~> 2.0' + spec.add_development_dependency 'guard-rspec', '~> 4.7' + spec.add_development_dependency 'guard-rubocop', '~> 1.3' spec.add_development_dependency 'pry', '~> 0.11' spec.add_development_dependency 'rake', '~> 10.0' spec.add_development_dependency 'rspec', '~> 3.0' spec.add_development_dependency 'simplecov', '~> 0.15' - spec.add_development_dependency 'wetransfer_style', '0.6.0' - spec.add_development_dependency 'guard-rspec', '~> 4.7' - spec.add_development_dependency 'guard-rubocop', '~> 1.3' + spec.add_development_dependency 'webmock' + spec.add_development_dependency 'wetransfer_style', '0.6.4' end # spec.add_development_dependency 'guard-flay' From 9ee34dcaf5533bf2dfb32c547a570abec3257d8e Mon Sep 17 00:00:00 2001 From: Arno Date: Fri, 22 Feb 2019 10:44:07 +0100 Subject: [PATCH 02/18] DRYed up File and MiniIO interaction - - DRY up request headers - NullMiniIO: all methods are empty - rename old spec files so they wont run, move them to a specific directory transform request params to JSON as late (and as once) as possible add more old spec files to the ignore list - Add RemoteFile and RemoteTransfer modules that change the corresponding instances *in place*. It sets instance variables and getter methods. - Transfer.find works, very limited test, though - Make upgrading a transfer (a bit) less magical. finalize call for transfers operational File upload implemented Implement file completion Refactorings for readability, less complexity Move communication to CommunicationHelper class More focus in integration test Expose #to_json on a transfer Refactoring * use class< 0 bytes spec/transfer_integration_spec.rb | 54 --- spec/we_transfer/communication_helper_spec.rb | 11 +- spec/we_transfer/mini_io_spec.rb | 54 ++- spec/we_transfer/transfer_spec.rb | 382 ++++++++++++++++-- spec/we_transfer/we_transfer_file_spec.rb | 32 +- spec/we_transfer_client/board_builder_spe.rb | 66 --- spec/we_transfer_client/boards_spe.rb | 56 --- spec/we_transfer_client/future_board_spe.rb | 98 ----- spec/we_transfer_client/future_file_spe.rb | 48 --- spec/we_transfer_client/future_link_spe.rb | 44 -- .../we_transfer_client/future_transfer_spe.rb | 39 -- spec/we_transfer_client/remote_board_spe.rb | 92 ----- spec/we_transfer_client/remote_file_spe.rb | 91 ----- spec/we_transfer_client/remote_link_spe.rb | 33 -- .../we_transfer_client/remote_transfer_spe.rb | 102 ----- .../transfer_builder_spe.rb | 58 --- spec/we_transfer_client/transfers_spe.rb | 45 --- spec/we_transfer_client_spec.rb | 41 +- wetransfer.gemspec | 13 +- 46 files changed, 816 insertions(+), 1752 deletions(-) create mode 100644 lib/we_transfer/remote_file.rb rename lib/{we_transfer_client => we_transfer}/version.rb (100%) delete mode 100644 lib/we_transfer_client/board_builder.rb delete mode 100644 lib/we_transfer_client/boards.rb delete mode 100644 lib/we_transfer_client/future_board.rb delete mode 100644 lib/we_transfer_client/future_file.rb delete mode 100644 lib/we_transfer_client/future_link.rb delete mode 100644 lib/we_transfer_client/future_transfer.rb delete mode 100644 lib/we_transfer_client/remote_board.rb delete mode 100644 lib/we_transfer_client/remote_file.rb delete mode 100644 lib/we_transfer_client/remote_link.rb delete mode 100644 lib/we_transfer_client/remote_transfer.rb delete mode 100644 lib/we_transfer_client/transfer_builder.rb delete mode 100644 lib/we_transfer_client/transfers.rb delete mode 100644 spec/board_integration_spec.rb delete mode 100644 spec/features/add_items_to_board_spec.rb create mode 100644 spec/features/client_creates_a_transfer_spec.rb delete mode 100644 spec/features/create_board_spec.rb delete mode 100644 spec/features/get_board_spec.rb delete mode 100644 spec/features/transfer_spec.rb delete mode 100644 spec/fixtures/Japan-02.jpg delete mode 100644 spec/transfer_integration_spec.rb delete mode 100644 spec/we_transfer_client/board_builder_spe.rb delete mode 100644 spec/we_transfer_client/boards_spe.rb delete mode 100644 spec/we_transfer_client/future_board_spe.rb delete mode 100644 spec/we_transfer_client/future_file_spe.rb delete mode 100644 spec/we_transfer_client/future_link_spe.rb delete mode 100644 spec/we_transfer_client/future_transfer_spe.rb delete mode 100644 spec/we_transfer_client/remote_board_spe.rb delete mode 100644 spec/we_transfer_client/remote_file_spe.rb delete mode 100644 spec/we_transfer_client/remote_link_spe.rb delete mode 100644 spec/we_transfer_client/remote_transfer_spe.rb delete mode 100644 spec/we_transfer_client/transfer_builder_spe.rb delete mode 100644 spec/we_transfer_client/transfers_spe.rb diff --git a/.rubocop.yml b/.rubocop.yml index 8dcf8d3..8f3e28a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,2 +1,2 @@ inherit_gem: - wetransfer_style: ruby/default.yml \ No newline at end of file + wetransfer_style: ruby/default.yml diff --git a/lib/we_transfer/communication_helper.rb b/lib/we_transfer/communication_helper.rb index 13fa876..68c3eb4 100644 --- a/lib/we_transfer/communication_helper.rb +++ b/lib/we_transfer/communication_helper.rb @@ -1,82 +1,167 @@ module WeTransfer class CommunicationError < StandardError; end + module CommunicationHelper - API_URI_BASE = 'https://dev.wetransfer.com'.freeze + extend Forwardable + + API_URL_BASE = "https://dev.wetransfer.com" + DEFAULT_HEADERS = { + "User-Agent" => "WetransferRubySdk/#{WeTransfer::VERSION} Ruby #{RUBY_VERSION}", + "Content-Type" => "application/json" + }.freeze class << self - attr_accessor :logger, :api_key - end + attr_accessor :logger, :api_key, :bearer_token - def logger - @logger ||= CommunicationHelper.logger - end + def reset_authentication! + @api_key = nil + @bearer_token = nil + @request_as = nil + end - def api_key - @api_key ||= CommunicationHelper.api_key - end + def find_transfer(transfer_id) + response = request_as.get("/v2/transfers/%s" % [transfer_id]) + ensure_ok_status!(response) + response_body = remote_transfer_params(response.body) + found_transfer = Transfer.new(message: response_body[:message]) + setup_transfer( + transfer: found_transfer, + data: response_body + ) + end - def request_as - authorize_if_no_bearer_token! + def upload_url_for_chunk(transfer_id, file_id, chunk) + response = request_as.get("/v2/transfers/%s/files/%s/upload-url/%s" % [transfer_id, file_id, chunk]) + ensure_ok_status!(response) - @request_as ||= Faraday.new(API_URI_BASE) do |c| - c.response :logger, logger - c.adapter Faraday.default_adapter - c.headers = auth_headers.merge( - "User-Agent" => "WetransferRubySdk/#{WeTransfer::VERSION} Ruby #{RUBY_VERSION}", - "Content-Type" => "application/json", + JSON.parse(response.body).fetch("url") + end + + def persist_transfer(transfer) + response = request_as.post( + "/v2/transfers", + transfer.as_request_params.to_json, + ) + ensure_ok_status!(response) + + handle_new_transfer_data( + transfer: transfer, + data: remote_transfer_params(response.body) ) end - end - private + def finalize_transfer(transfer) + response = request_as.put("/v2/transfers/%s/finalize" % transfer.id) + ensure_ok_status!(response) + handle_new_transfer_data( + transfer: transfer, + data: remote_transfer_params(response.body) + ) + end - def auth_headers - authorize_if_no_bearer_token! + def remote_transfer_params(response_body) + JSON.parse(response_body, symbolize_names: true) + end - { - 'X-API-Key' => api_key, - 'Authorization' => ('Bearer %s' % @bearer_token), - } - end + def upload_chunk(put_url, chunk_contents) + @chunk_uploader ||= Faraday.new { |c| minimal_faraday_config(c) } - def ensure_ok_status!(response) - case response.status - when 200..299 - true - when 400..499 - logger.error response - raise WeTransfer::CommunicationError, JSON.parse(response.body)["message"] - when 500..504 - logger.error response - raise WeTransfer::CommunicationError, "Response had a #{response.status} code, we could retry" - else - logger.error response - raise WeTransfer::CommunicationError, "Response had a #{response.status} code, no idea what to do with that" + @chunk_uploader.put( + put_url, + chunk_contents.read, + 'Content-Type' => 'binary/octet-stream', + 'Content-Length' => chunk_contents.size.to_s + ) end - end - def authorize_if_no_bearer_token! - return @bearer_token if @bearer_token + def complete_file(transfer_id, file_id, chunks) + response = request_as.put( + "/v2/transfers/%s/files/%s/upload-complete" % [transfer_id, file_id], + { part_numbers: chunks }.to_json + ) + + ensure_ok_status!(response) + remote_transfer_params(response.body) + end + + private + + def request_as + @request_as ||= Faraday.new(API_URL_BASE) do |c| + minimal_faraday_config(c) + c.headers = auth_headers.merge DEFAULT_HEADERS + end + end + + def setup_transfer(transfer:, data:) + data[:files].each do |file_params| + transfer.add_file( + name: file_params[:name], + size: file_params[:size], + ) + end + + handle_new_transfer_data(transfer: transfer, data: data) + end + + def handle_new_transfer_data(transfer:, data:) + %i[id state url].each do |i_var| + transfer.instance_variable_set "@#{i_var}", data[i_var] + end + + RemoteFile.upgrade( + transfer: transfer, + files_response: data[:files] + ) + transfer + end - response = Faraday.new(API_URI_BASE) do |c| - c.response :logger, logger - c.adapter Faraday.default_adapter - c.headers = { - "User-Agent" => "WetransferRubySdk/#{WeTransfer::VERSION} Ruby #{RUBY_VERSION}", - "Content-Type" => "application/json", + def auth_headers + authorize_if_no_bearer_token! + + { + 'X-API-Key' => api_key, + 'Authorization' => "Bearer #{@bearer_token}" } - end.post( - '/v2/authorize', - '', - 'Content-Type' => 'application/json', - 'X-API-Key' => CommunicationHelper.api_key, - ) - ensure_ok_status!(response) - bearer_token = JSON.parse(response.body)['token'] - if bearer_token.nil? || bearer_token.empty? - raise WeTransfer::CommunicationError, "The authorization call returned #{response.body} and no usable :token key could be found there" end - @bearer_token = bearer_token + + def ensure_ok_status!(response) + case response.status + when 200..299 + true + when 400..499 + logger.error response + raise WeTransfer::CommunicationError, JSON.parse(response.body)["message"] + when 500..504 + logger.error response + raise WeTransfer::CommunicationError, "Response had a #{response.status} code, we could retry" + else + logger.error response + raise WeTransfer::CommunicationError, "Response had a #{response.status} code, no idea what to do with that" + end + end + + def authorize_if_no_bearer_token! + return @bearer_token if @bearer_token + + response = Faraday.new(API_URL_BASE) do |c| + minimal_faraday_config(c) + c.headers = DEFAULT_HEADERS.merge('X-API-Key' => api_key) + end.post( + '/v2/authorize', + ) + ensure_ok_status!(response) + bearer_token = JSON.parse(response.body)['token'] + raise WeTransfer::CommunicationError, "The authorization call returned #{response.body} and no usable :token key could be found there" if bearer_token.nil? || bearer_token.empty? + @bearer_token = bearer_token + end + + def minimal_faraday_config(config) + config.response :logger, logger + config.adapter Faraday.default_adapter + end end + + def_delegator self, :minimal_faraday_config end end diff --git a/lib/we_transfer/mini_io.rb b/lib/we_transfer/mini_io.rb index abf7d08..f7f50ff 100644 --- a/lib/we_transfer/mini_io.rb +++ b/lib/we_transfer/mini_io.rb @@ -2,13 +2,40 @@ module WeTransfer # Wrapper around an IO object, delegating only methods we need for creating # and sending a file in chunks. # + class MiniIO + def self.mini_io_able?(io) + return false if io.is_a? WeTransfer::NullMiniIO + + io.seek(0) + io.read(1) + io.rewind + io.size + true + rescue + false + end + + # Make sure MiniIO does not wrap a MiniIO instance + # + # @param io [anything] An object that responds to #read, #rewind, #seek, #size + # + def self.new(io) + return WeTransfer::NullMiniIO.instance if io.nil? + return io if io.is_a? WeTransfer::MiniIO + + super + end + # Initialize with an io object to wrap # # @param io [anything] An object that responds to #read, #rewind, #seek, #size # def initialize(io) + ensure_mini_io_able!(io) + @io = io + @io.rewind end def read(*args) @@ -23,25 +50,49 @@ def seek(*args) @io.seek(*args) end - # The size delegated to io. - # If io is nil, we return nil. + # The size, delegated to io. # # nil is fine, since this method is used only as the default size for a # WeTransferFile - # @returns [Integer, nil] the size of the io. See IO#size + # @returns [Integer] the size of the io # def size - @io&.size + @io.size end # The name of the io, guessed using File.basename. If this raises a TypeError - # we swallow the error, since this is used only as the default name for a - # WeTransferFile + # we swallow the error, since this is used only as the default name for a + # WeTransferFile # def name File.basename(@io) rescue TypeError # yeah, what? end + + private + + def ensure_mini_io_able!(io) + return if self.class.mini_io_able?(io) + + raise ArgumentError, "The io must respond to seek(), read(), size() and rewind(), but #{io.inspect} did not" + end + end + + class NullMiniIO + require 'singleton' + include Singleton + + instance.freeze + + def read(*); end + + def rewind; end + + def seek(*); end + + def size; end + + def name; end end end diff --git a/lib/we_transfer/remote_file.rb b/lib/we_transfer/remote_file.rb new file mode 100644 index 0000000..1017efd --- /dev/null +++ b/lib/we_transfer/remote_file.rb @@ -0,0 +1,33 @@ +module WeTransfer + module RemoteFile + class FileMismatchError < StandardError; end + class NoIoError < StandardError; end + + class Multipart < ::Ks.strict(:chunks, :chunk_size) + def to_h + %i[chunks chunk_size].each_with_object({}) do |prop, memo| + memo[prop] = send(prop) + end + end + end + + def self.upgrade(files_response:, transfer:) + files_response.each do |file_response| + local_file = transfer.find_file_by_name(file_response[:name]) + + local_file.instance_variable_set( + :@id, + file_response[:id] + ) + + local_file.instance_variable_set( + :@multipart, + Multipart.new( + chunks: file_response[:multipart][:part_numbers], + chunk_size: file_response[:multipart][:chunk_size], + ) + ) + end + end + end +end diff --git a/lib/we_transfer/transfer.rb b/lib/we_transfer/transfer.rb index 2640b88..0d8b26d 100644 --- a/lib/we_transfer/transfer.rb +++ b/lib/we_transfer/transfer.rb @@ -2,8 +2,16 @@ module WeTransfer class Transfer class DuplicateFileNameError < ArgumentError; end class NoFilesAddedError < StandardError; end + class FileMismatchError < StandardError; end - include CommunicationHelper + extend Forwardable + + attr_reader :files, :id, :state, :url, :message + + class << self + extend Forwardable + def_delegator CommunicationHelper, :find_transfer, :find + end def self.create(message:, &block) transfer = new(message: message) @@ -17,50 +25,88 @@ def initialize(message:) @unique_file_names = Set.new end - # Add files (if still needed) def persist yield(self) if block_given? + raise NoFilesAddedError if @unique_file_names.empty? - create_remote_transfer - - ## files should now be in persisted status - + CommunicationHelper.persist_transfer(self) end # Add one or more files to a transfer, so a transfer can be created over the # WeTransfer public API # - # @param name [String] (nil) the name of the file + # @params name [String] (nil) the name of the file # - # @return [WeTransfer::Client] - def add_file(name: nil, size: nil, io: nil) - file = WeTransferFile.new(name: name, size: size, io: io) + # @returns self [WeTransfer::Client] + def add_file(**args) + file = WeTransferFile.new(args) raise DuplicateFileNameError unless @unique_file_names.add?(file.name.downcase) @files << file self end - private + def upload_file(name:, io: nil) + file = find_file_by_name(name) + put_io = io || file.io + + raise( + WeTransfer::RemoteFile::NoIoError, + "IO for file with name '#{name}' cannot be uploaded." + ) unless WeTransfer::MiniIO.mini_io_able?(put_io) + + (1..file.multipart.chunks).each do |chunk| + put_url = upload_url_for_chunk(name: name, chunk: chunk) + chunk_contents = StringIO.new(put_io.read(file.multipart.chunk_size)) + chunk_contents.rewind + + CommunicationHelper.upload_chunk(put_url, chunk_contents) + end + end + + def upload_url_for_chunk(name:, chunk:) + file_id = find_file_by_name(name).id + CommunicationHelper.upload_url_for_chunk(id, file_id, chunk) + end + + def complete_file(name:) + file = find_file_by_name(name) + CommunicationHelper.complete_file(id, file.id, file.multipart.chunks) + end - def as_json_request_params + def finalize + CommunicationHelper.finalize_transfer(self) + end + + def as_request_params { message: @message, - files: @files.map(&:as_json_request_params), + files: @files.map(&:as_request_params), } end - def create_remote_transfer - raise NoFilesAddedError if @unique_file_names.empty? + def to_json + to_h.to_json + end - response = request_as.post( - '/v2/transfers', - as_json_request_params.to_json, - {} - ) - ensure_ok_status!(response) + def to_h + prepared = %i[id state url message].each_with_object({}) do |prop, memo| + memo[prop] = send(prop) + end - @remote_transfer = RemoteTransfer.new(JSON.parse(response.body, symbolize_names: true)) + prepared[:files] = files.map(&:to_h) + prepared end + + def find_file_by_name(name) + @found_files ||= Hash.new do |h, name| + h[name] = files.find { |file| file.name == name } + end + + raise FileMismatchError unless @found_files[name] + @found_files[name] + end + + def_delegator self, :remote_transfer_params end end diff --git a/lib/we_transfer_client/version.rb b/lib/we_transfer/version.rb similarity index 100% rename from lib/we_transfer_client/version.rb rename to lib/we_transfer/version.rb diff --git a/lib/we_transfer/we_transfer_file.rb b/lib/we_transfer/we_transfer_file.rb index 666ac36..516f147 100644 --- a/lib/we_transfer/we_transfer_file.rb +++ b/lib/we_transfer/we_transfer_file.rb @@ -1,23 +1,29 @@ module WeTransfer class WeTransferFile - - attr_reader :name + attr_reader :name, :id, :io, :multipart, :size def initialize(name: nil, size: nil, io: nil) - @io = io.is_a?(MiniIO) ? io : MiniIO.new(io) + @io = MiniIO.new(io) @name = name || @io.name @size = size || @io.size raise ArgumentError, "Need a file name and a size, or io should provide it" unless @name && @size end - def as_json_request_params + def as_request_params { name: @name, size: @size, } end - # def persist() + def to_h + prepared = %i[name size id].each_with_object({}) do |prop, memo| + memo[prop] = send(prop) + end + prepared[:multipart] = multipart.to_h + + prepared + end end end diff --git a/lib/we_transfer_client.rb b/lib/we_transfer_client.rb index 4e5d70e..6561321 100644 --- a/lib/we_transfer_client.rb +++ b/lib/we_transfer_client.rb @@ -3,86 +3,43 @@ require 'faraday' require 'logger' require 'json' +require 'ks' -require_relative 'we_transfer_client/version' -require_relative 'we_transfer_client/transfer_builder' -require_relative 'we_transfer_client/board_builder' -require_relative 'we_transfer_client/future_file' -require_relative 'we_transfer_client/future_link' -require_relative 'we_transfer_client/future_transfer' -require_relative 'we_transfer_client/future_board' -require_relative 'we_transfer_client/remote_transfer' -require_relative 'we_transfer_client/remote_board' -require_relative 'we_transfer_client/remote_link' -require_relative 'we_transfer_client/remote_file' -require_relative 'we_transfer_client/transfers' -require_relative 'we_transfer_client/boards' - -%w[communication_helper transfer mini_io we_transfer_file ].each do |file| +%w[communication_helper transfer mini_io we_transfer_file remote_file version].each do |file| require_relative "we_transfer/#{file}" end module WeTransfer class Client + class Error < StandardError; end include CommunicationHelper - class Error < StandardError; end NullLogger = Logger.new(nil) - API_URL_BASE = 'https://dev.wetransfer.com' - - # include WeTransfer::Client::Transfers - # include WeTransfer::Client::Boards + attr_reader :transfer - ## initialize a WeTransfer::Client + # Initialize a WeTransfer::Client # # @param api_key [String] The API key you want to authenticate with # @param logger [Logger] (NullLogger) your custom logger # # @return [WeTransfer::Client] def initialize(api_key:, logger: NullLogger) + CommunicationHelper.reset_authentication! CommunicationHelper.api_key = api_key - @bearer_token = nil - @logger = logger CommunicationHelper.logger = logger end def create_transfer(**args, &block) - transfer = WeTransfer::Transfer.new(args, &block) + transfer = WeTransfer::Transfer.new(args) + transfer.persist(&block) @transfer = transfer - # TODO: Either we have an accessor for transfer, or we're not returning self - the transfer is unavailable otherwise self end - # def upload_file(object:, file:, io:) - # put_io_in_parts(object: object, file: file, io: io) - # end - - # def complete_file!(object:, file:) - # object.prepare_file_completion(client: self, file: file) - # end - - # def check_for_file_duplicates(files, new_file) - # if files.select { |file| file.name == new_file.name }.size != 1 - # raise ArgumentError, 'Duplicate file entry' - # end - # end - - # def put_io_in_parts(object:, file:, io:) - # (1..file.multipart.part_numbers).each do |part_n_one_based| - # upload_url, chunk_size = object.prepare_file_upload(client: self, file: file, part_number: part_n_one_based) - # part_io = StringIO.new(io.read(chunk_size)) - # part_io.rewind - # response = request_as.put( - # upload_url, - # part_io, - # 'Content-Type' => 'binary/octet-stream', - # 'Content-Length' => part_io.size.to_s - # ) - # ensure_ok_status!(response) - # end - # {success: true, message: 'File Uploaded'} - # end + def find_transfer(transfer_id) + @transfer = WeTransfer::Transfer.find(transfer_id) + end end end diff --git a/lib/we_transfer_client/board_builder.rb b/lib/we_transfer_client/board_builder.rb deleted file mode 100644 index d5444b5..0000000 --- a/lib/we_transfer_client/board_builder.rb +++ /dev/null @@ -1,33 +0,0 @@ -class BoardBuilder - attr_reader :items - class TransferIOError < StandardError; end - - def initialize - @items = [] - end - - def add_file(name:, io:) - ensure_io_compliant!(io) - @items << FutureFile.new(name: name, io: io) - end - - def add_file_at(path:) - add_file(name: File.basename(path), io: File.open(path, 'rb')) - end - - def add_web_url(url:, title: url) - @items << FutureLink.new(url: url, title: title) - end - - private - - def ensure_io_compliant!(io) - io.seek(0) - io.read(1) # Will cause things like Errno::EACCESS to happen early, before the upload begins - io.seek(0) # Also rewinds the IO for later uploading action - size = io.size # Will cause a NoMethodError - raise TransferIOError, "#{File.basename(io)}, given to add_file has a size of 0" if size <= 0 - rescue NoMethodError - raise TransferIOError, "#{File.basename(io)}, given to add_file must respond to seek(), read() and size(), but #{io.inspect} did not" - end -end diff --git a/lib/we_transfer_client/boards.rb b/lib/we_transfer_client/boards.rb deleted file mode 100644 index 7669bb4..0000000 --- a/lib/we_transfer_client/boards.rb +++ /dev/null @@ -1,74 +0,0 @@ -module WeTransfer - class Client - module Boards - def create_board_and_upload_items(name:, description:, &block) - future_board = create_feature_board(name: name, description: description, &block) - remote_board = create_remote_board(board: future_board) - remote_board.files.each do |file| - check_for_file_duplicates(future_board.files, file) - local_file = future_board.files.select { |x| x.name == file.name }.first - upload_file(object: remote_board, file: file, io: local_file.io) - complete_file!(object: remote_board, file: file) - end - remote_board - end - - def get_board(board:) - request_board(board: board) - end - - private - - def create_board(name:, description:, &block) - future_board = create_feature_board(name: name, description: description, &block) - create_remote_board(board: future_board) - end - - def add_items(board:, board_builder_class: BoardBuilder) - builder = board_builder_class.new - yield(builder) - add_items_to_remote_board(items: builder.items, remote_board: board) - rescue LocalJumpError - raise ArgumentError, 'No items where added to the board' - end - - def create_feature_board(name:, description:, future_board_class: FutureBoard, board_builder_class: BoardBuilder) - builder = board_builder_class.new - yield(builder) if block_given? - future_board_class.new(name: name, description: description, items: builder.items) - end - - def create_remote_board(board:, remote_board_class: RemoteBoard) - authorize_if_no_bearer_token! - response = request_as.post( - '/v2/boards', - JSON.generate(board.to_initial_request_params), - auth_headers.merge('Content-Type' => 'application/json') - ) - ensure_ok_status!(response) - remote_board = remote_board_class.new(JSON.parse(response.body, symbolize_names: true)) - board.items.any? ? add_items_to_remote_board(items: board.items, remote_board: remote_board) : remote_board - end - - def add_items_to_remote_board(items:, remote_board:) - items.group_by(&:class).each do |_, grouped_items| - grouped_items.each do |item| - item.add_to_board(client: self, remote_board: remote_board) - end - end - remote_board - end - - def request_board(board:, remote_board_class: RemoteBoard) - authorize_if_no_bearer_token! - response = request_as.get( - "/v2/boards/#{board.id}", - {}, - auth_headers.merge('Content-Type' => 'application/json') - ) - ensure_ok_status!(response) - remote_board_class.new(JSON.parse(response.body, symbolize_names: true)) - end - end - end -end diff --git a/lib/we_transfer_client/future_board.rb b/lib/we_transfer_client/future_board.rb deleted file mode 100644 index cfaf6fc..0000000 --- a/lib/we_transfer_client/future_board.rb +++ /dev/null @@ -1,32 +0,0 @@ -class FutureBoard - attr_reader :name, :description, :items - - def initialize(name:, description: nil, items: []) - @name = name - @description = description - @items = items - end - - def files - @items.select { |item| item.class == FutureFile } - end - - def links - @items.select { |item| item.class == FutureLink } - end - - def to_initial_request_params - { - name: name, - description: description, - } - end - - def as_json_request_params - { - name: name, - description: description, - items: items.map(&:as_json_request_params), - }.to_json - end -end diff --git a/lib/we_transfer_client/future_file.rb b/lib/we_transfer_client/future_file.rb deleted file mode 100644 index 81ea70d..0000000 --- a/lib/we_transfer_client/future_file.rb +++ /dev/null @@ -1,28 +0,0 @@ -class FutureFile - attr_reader :name, :io - - def initialize(name:, io:) - @name = name - @io = io - end - - def as_json_request_params - { - name: @name, - size: @io.size, - } - end - - def add_to_board(client:, remote_board:) - client.authorize_if_no_bearer_token! - response = client.request_as.post( - "/v2/boards/#{remote_board.id}/files", - # this needs to be a array with hashes => [{name, filesize}] - JSON.generate([as_json_request_params]), - client.auth_headers.merge('Content-Type' => 'application/json') - ) - client.ensure_ok_status!(response) - file_item = JSON.parse(response.body, symbolize_names: true).first - remote_board.items << RemoteFile.new(file_item) - end -end diff --git a/lib/we_transfer_client/future_link.rb b/lib/we_transfer_client/future_link.rb deleted file mode 100644 index dd95c71..0000000 --- a/lib/we_transfer_client/future_link.rb +++ /dev/null @@ -1,28 +0,0 @@ -class FutureLink - attr_reader :url, :title - - def initialize(url:, title: url) - @url = url - @title = title - end - - def as_json_request_params - { - url: url, - title: title, - } - end - - def add_to_board(client:, remote_board:) - client.authorize_if_no_bearer_token! - response = client.request_as.post( - "/v2/boards/#{remote_board.id}/links", - # this needs to be a array with hashes => [{name, filesize}] - JSON.generate([as_json_request_params]), - client.auth_headers.merge('Content-Type' => 'application/json') - ) - client.ensure_ok_status!(response) - file_item = JSON.parse(response.body, symbolize_names: true).first - remote_board.items << RemoteLink.new(file_item) - end -end diff --git a/lib/we_transfer_client/future_transfer.rb b/lib/we_transfer_client/future_transfer.rb deleted file mode 100644 index 0892375..0000000 --- a/lib/we_transfer_client/future_transfer.rb +++ /dev/null @@ -1,15 +0,0 @@ -class FutureTransfer - attr_accessor :message, :files - - def initialize(message:, files: []) - @message = message - @files = files - end - - def as_json_request_params - { - message: message, - files: files.map(&:as_json_request_params), - } - end -end diff --git a/lib/we_transfer_client/remote_board.rb b/lib/we_transfer_client/remote_board.rb deleted file mode 100644 index 3e3c4d6..0000000 --- a/lib/we_transfer_client/remote_board.rb +++ /dev/null @@ -1,47 +0,0 @@ -class RemoteBoard - ItemTypeError = Class.new(NameError) - - attr_reader :id, :items, :url, :state - - CHUNK_SIZE = 6 * 1024 * 1024 - - def initialize(id:, state:, url:, name:, description: '', items: [], **) - @id = id - @state = state - @url = url - @name = name - @description = description - @items = to_instances(items: items) - end - - def prepare_file_upload(client:, file:, part_number:) - url = file.request_board_upload_url(client: client, board_id: @id, part_number: part_number) - [url, CHUNK_SIZE] - end - - def prepare_file_completion(client:, file:) - file.complete_board_file(client: client, board_id: @id) - end - - def files - @items.select { |item| item.class == RemoteFile } - end - - def links - @items.select { |item| item.class == RemoteLink } - end - - private - - def to_instances(items:) - items.map do |item| - begin - remote_class = "Remote#{item[:type].capitalize}" - Module.const_get(remote_class) - .new(item) - rescue NameError - raise ItemTypeError, "Cannot instantiate item with type '#{item[:type]}' and id '#{item[:id]}'" - end - end - end -end diff --git a/lib/we_transfer_client/remote_file.rb b/lib/we_transfer_client/remote_file.rb deleted file mode 100644 index c4d7829..0000000 --- a/lib/we_transfer_client/remote_file.rb +++ /dev/null @@ -1,55 +0,0 @@ -class RemoteFile - attr_reader :multipart, :name, :id, :url, :type - - def initialize(id:, name:, size:, url: nil, type: 'file', multipart:) - @id = id - @name = name - @size = size - @url = url - @type = type - @size = size - multi = Struct.new(*multipart.keys) - @multipart = multi.new(*multipart.values) - end - - def request_transfer_upload_url(client:, transfer_id:, part_number:) - response = client.request_as.get( - "/v2/transfers/#{transfer_id}/files/#{@id}/upload-url/#{part_number}", - {}, - client.auth_headers.merge('Content-Type' => 'application/json') - ) - client.ensure_ok_status!(response) - JSON.parse(response.body, symbolize_names: true).fetch(:url) - end - - def request_board_upload_url(client:, board_id:, part_number:) - response = client.request_as.get( - "/v2/boards/#{board_id}/files/#{@id}/upload-url/#{part_number}/#{@multipart.id}", - {}, - client.auth_headers.merge('Content-Type' => 'application/json') - ) - client.ensure_ok_status!(response) - JSON.parse(response.body, symbolize_names: true).fetch(:url) - end - - def complete_transfer_file(client:, transfer_id:) - body = {part_numbers: @multipart.part_numbers} - response = client.request_as.put( - "/v2/transfers/#{transfer_id}/files/#{@id}/upload-complete", - JSON.generate(body), - client.auth_headers.merge('Content-Type' => 'application/json') - ) - client.ensure_ok_status!(response) - JSON.parse(response.body, symbolize_names: true) - end - - def complete_board_file(client:, board_id:) - response = client.request_as.put( - "/v2/boards/#{board_id}/files/#{@id}/upload-complete", - '{}', - client.auth_headers.merge('Content-Type' => 'application/json') - ) - client.ensure_ok_status!(response) - JSON.parse(response.body, symbolize_names: true) - end -end diff --git a/lib/we_transfer_client/remote_link.rb b/lib/we_transfer_client/remote_link.rb deleted file mode 100644 index 48a82bc..0000000 --- a/lib/we_transfer_client/remote_link.rb +++ /dev/null @@ -1,9 +0,0 @@ -class RemoteLink - attr_reader :type - def initialize(id:, url:, type:, meta:) - @id = id - @url = url - @title = meta.fetch(:title) - @type = type - end -end diff --git a/lib/we_transfer_client/remote_transfer.rb b/lib/we_transfer_client/remote_transfer.rb deleted file mode 100644 index b268bd2..0000000 --- a/lib/we_transfer_client/remote_transfer.rb +++ /dev/null @@ -1,25 +0,0 @@ -class RemoteTransfer - attr_reader :files, :url, :state, :id - - def initialize(id:, state:, url:, message:, files: [], **) - @id = id - @state = state - @message = message - @url = url - @files = files_to_class(files) - end - - def prepare_file_upload(client:, file:, part_number:) - url = file.request_transfer_upload_url(client: client, transfer_id: @id, part_number: part_number) - chunk_size = file.multipart.chunk_size - [url, chunk_size] - end - - def prepare_file_completion(client:, file:) - file.complete_transfer_file(client: client, transfer_id: @id) - end - - def files_to_class(files) - files.map { |x| RemoteFile.new(x) } - end -end diff --git a/lib/we_transfer_client/transfer_builder.rb b/lib/we_transfer_client/transfer_builder.rb deleted file mode 100644 index 6bc5080..0000000 --- a/lib/we_transfer_client/transfer_builder.rb +++ /dev/null @@ -1,27 +0,0 @@ -class TransferBuilder - attr_reader :files - class TransferIOError < StandardError; end - - def initialize - @files = [] - end - - def add_file(name:, io:) - ensure_io_compliant!(io) - @files << FutureFile.new(name: name, io: io) - end - - def add_file_at(path:) - add_file(name: File.basename(path), io: File.open(path, 'rb')) - end - - def ensure_io_compliant!(io) - io.seek(0) - io.read(1) # Will cause things like Errno::EACCESS to happen early, before the upload begins - io.seek(0) # Also rewinds the IO for later uploading action - size = io.size # Will cause a NoMethodError - raise TransferIOError, 'The IO object given to add_file has a size of 0' if size <= 0 - rescue NoMethodError - raise TransferIOError, "The IO object given to add_file must respond to seek(), read() and size(), but #{io.inspect} did not" - end -end diff --git a/lib/we_transfer_client/transfers.rb b/lib/we_transfer_client/transfers.rb deleted file mode 100644 index 2105128..0000000 --- a/lib/we_transfer_client/transfers.rb +++ /dev/null @@ -1,73 +0,0 @@ -module WeTransfer - class Client - module Transfers - def create_transfer_and_upload_files(message:, &block) - future_transfer = create_future_transfer(message: message, &block) - remote_transfer = create_remote_transfer(future_transfer) - remote_transfer.files.each do |file| - check_for_file_duplicates(future_transfer.files, file) - local_file = future_transfer.files.select { |x| x.name == file.name }.first - upload_file(object: remote_transfer, file: file, io: local_file.io) - complete_file!(object: remote_transfer, file: file) - end - complete_transfer(transfer: remote_transfer) - end - - def get_transfer(transfer_id:) - request_transfer(transfer_id) - end - - private - - def create_transfer(message:, &block) - transfer = create_future_transfer(message: message, &block) - create_remote_transfer(transfer) - end - - def complete_transfer(transfer:) - complete_transfer_call(transfer) - end - - def create_future_transfer(message:, future_transfer_class: FutureTransfer, transfer_builder_class: TransferBuilder) - builder = transfer_builder_class.new - yield(builder) - future_transfer_class.new(message: message, files: builder.files) - rescue LocalJumpError - raise ArgumentError, 'No files were added to transfer' - end - - def create_remote_transfer(xfer) - authorize_if_no_bearer_token! - response = request_as.post( - '/v2/transfers', - JSON.generate(xfer.as_json_request_params), - auth_headers.merge('Content-Type' => 'application/json') - ) - ensure_ok_status!(response) - RemoteTransfer.new(JSON.parse(response.body, symbolize_names: true)) - end - - def complete_transfer_call(object) - authorize_if_no_bearer_token! - response = request_as.put( - "/v2/transfers/#{object.id}/finalize", - '', - auth_headers.merge('Content-Type' => 'application/json') - ) - ensure_ok_status!(response) - RemoteTransfer.new(JSON.parse(response.body, symbolize_names: true)) - end - - def request_transfer(transfer_id) - authorize_if_no_bearer_token! - response = request_as.get( - "/v2/transfers/#{transfer_id}", - {}, - auth_headers.merge('Content-Type' => 'application/json') - ) - ensure_ok_status!(response) - RemoteTransfer.new(JSON.parse(response.body, symbolize_names: true)) - end - end - end -end diff --git a/spec/board_integration_spec.rb b/spec/board_integration_spec.rb deleted file mode 100644 index c88cfea..0000000 --- a/spec/board_integration_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -require 'spec_helper' - -describe WeTransfer::Client do - let(:small_file_name) { 'Japan-02.jpg' } - let(:big_file) { File.open(fixtures_dir + 'Japan-01.jpg', 'rb') } - let(:client) do - WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) - end - - it 'creates a board with files and web items as a block' do - # Create a board with three items, one small file, one multipart file, and one web url - board = client.create_board_and_upload_items(name: 'Test Board', description: 'Test description') do |b| - b.add_file(name: File.basename(__FILE__), io: File.open(__FILE__, 'rb')) - b.add_file(name: 'big file', io: big_file) - b.add_file_at(path: fixtures_dir + small_file_name) - b.add_web_url(url: 'http://www.wetransfer.com', title: 'WeTransfer Website') - end - - # the board url is set - expect(board.url =~ %r|https://we.tl/b-|).to be_truthy - - expect(board.items.map(&:class)).to eq([RemoteFile, RemoteFile, RemoteFile, RemoteLink]) - expect(board.items[1].multipart.part_numbers).to be > 1 - expect(board.items.count).to be(4) - - # TODO: make these experimental steps public - # # Add two new items to the board, one small file and one web url - # client.add_items(board: board) do |b| - # b.add_file(name: File.basename(__FILE__), io: File.open(__FILE__, 'rb')) - # b.add_web_url(url: 'http://www.google.com', title: 'google') - # end - # expect(board.items.count).to be(6) - - # # Check if the board includes 3 File items and 2 Link items - # expect(board.items.select { |i| i.type == 'file' }.count).to be(4) - # expect(board.items.select { |i| i.type == 'link' }.count).to be(2) - - # # Upload the Files to the Board - # file_items = board.items.select { |i| i.type == 'file' } - # client.upload_file(object: board, file: file_items[0], io: File.open(__FILE__, 'rb')) - # client.upload_file(object: board, file: file_items[1], io: big_file) - # client.upload_file(object: board, file: file_items[2], io: File.open(fixtures_dir + small_file_name, 'rb')) - # client.upload_file(object: board, file: file_items[3], io: File.open(__FILE__, 'rb')) - - # # Complete all the files of the board - # file_items.each do |item| - # client.complete_file!(object: board, file: item) - # end - - # Do a get request to see if url is available - response = Faraday.get(board.url) - - # that should redirect us... - expect(response.status).to eq(302) - # ... to a board in the wetransfer domain - expect(response['location']).to start_with('https://boards.wetransfer') - - # Check for the Boards status to be downloadable - resulting_board = loop do - res = client.get_board(board: board) - break res if res.state != 'processing' - sleep 1 - end - - expect(resulting_board.state).to eq('downloadable') - expect(resulting_board.items.count).to be(4) - end -end diff --git a/spec/features/add_items_to_board_spec.rb b/spec/features/add_items_to_board_spec.rb deleted file mode 100644 index d435cf8..0000000 --- a/spec/features/add_items_to_board_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -require 'spec_helper' - -describe WeTransfer::Client::Boards do - let(:client) { WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) } - let(:board) do - client.create_board(name: 'Test Board', description: 'Test Descritpion') - end - - describe '#add_items' do - before do - skip "this interface is still experimental" - end - - it 'adds items to a board' do - client.add_items(board: board) do |b| - b.add_file(name: File.basename(__FILE__), io: File.open(__FILE__, 'rb')) - b.add_file_at(path: fixtures_dir + 'Japan-02.jpg') - b.add_web_url(url: 'http://www.google.com', title: 'google') - end - end - - it 'fails when no block is given' do - expect { - client.add_items(board: board) - }.to raise_error ArgumentError, /No items/ - end - - it 'fails when no board is passed as keyword argument' do - expect { - client.add_items do |b| - b.add_file_at(path: fixtures_dir + 'Japan-01.jpg') - end - }.to raise_error ArgumentError, /board/ - end - - it 'fails when file is not found' do - expect { - client.add_items(board: board) do |b| - b.add_file(name: 'file_not_found.rb', io: File.open('/path/to/non-existent-file.rb', 'r')) - end - }.to raise_error Errno::ENOENT, /No such file/ - end - - it 'fails when board is not a existing remote board' do - new_board = RemoteBoard.new(id: 123456, state: 'proccessing', url: 'https://www.we.tl/123456', name: 'fake board') - expect { - client.add_items(board: new_board) do |b| - b.add_file(name: File.basename(__FILE__), io: File.open(__FILE__, 'rb')) - b.add_file_at(path: fixtures_dir + 'Japan-01.jpg') - b.add_web_url(url: 'http://www.google.com', title: 'google') - end - }.to raise_error WeTransfer::Client::Error, /404 code/ - end - end -end diff --git a/spec/features/client_creates_a_transfer_spec.rb b/spec/features/client_creates_a_transfer_spec.rb new file mode 100644 index 0000000..6370152 --- /dev/null +++ b/spec/features/client_creates_a_transfer_spec.rb @@ -0,0 +1,47 @@ +require "spec_helper" + +describe "transfer integration" do + around do |example| + WebMock.allow_net_connect! + example.run + WebMock.disable_net_connect! + end + + it "Create a client, a transfer, it uploads files and finalizes the transfer" do + client = WeTransfer::Client.new(api_key: ENV.fetch("WT_API_KEY")) + + client.create_transfer(message: "test transfer") do |transfer| + transfer.add_file(name: "small_file", size: 80) + transfer.add_file(name: "small_file_with_io", size: 10, io: StringIO.new("#" * 10)) + transfer.add_file(name: "multi_chunk_big_file", io: File.open('spec/fixtures/Japan-01.jpg')) + end + + transfer = client.transfer + + transfer.to_json + + expect(transfer.url) + .to be_nil + + expect(transfer.id.nil?).to eq false + expect(transfer.state).to eq "uploading" + expect(transfer.files.none? { |f| f.id.nil? }).to eq true + + transfer.upload_file(name: "small_file", io: StringIO.new("#" * 80)) + transfer.complete_file(name: "small_file") + + transfer.upload_file(name: "small_file_with_io") + transfer.complete_file(name: "small_file_with_io") + + transfer.upload_file(name: "multi_chunk_big_file") + transfer.complete_file(name: "multi_chunk_big_file") + + transfer.finalize + + expect(transfer.state).to eq "processing" + + # Your transfer is available (after processing) at `transfer.url` + expect(transfer.url) + .to match %r|https://we.tl/t-| + end +end diff --git a/spec/features/create_board_spec.rb b/spec/features/create_board_spec.rb deleted file mode 100644 index 33e4f7a..0000000 --- a/spec/features/create_board_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -require 'spec_helper' - -describe WeTransfer::Client::Boards do - let(:big_file) { File.open(fixtures_dir + 'Japan-01.jpg', 'r') } - let(:client) { WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) } - - describe '#create_board' do - before { - skip "this interface is still experimental" - } - - it 'creates a remote board' do - client.create_board(name: 'Test Board', description: 'Test Descritpion') - end - - it 'creates a board with items' do - client.create_board(name: 'Test Board', description: 'Test descrition') do |b| - b.add_file(name: File.basename(__FILE__), io: File.open(__FILE__, 'rb')) - b.add_file(name: 'big file', io: big_file) - b.add_web_url(url: 'http://www.wetransfer.com', title: 'WeTransfer Website') - end - end - - it 'fails when name is missing' do - expect { - client.create_board(name: '', description: 'Test Descritpion') - }.to raise_error WeTransfer::Client::Error, /400 code/ - end - - it 'fails when file path is wrong' do - expect { - client.create_board(name: 'Test Board', description: 'Test descrition') do |b| - b.add_file(name: 'file_not_found.rb', io: File.open('path/to/non-existing-file.rb', 'r')) - end - }.to raise_error Errno::ENOENT, /No such file/ - end - - it 'fails when file name is missing' do - expect { - client.create_board(name: 'Test Board', description: 'Test descrition') do |b| - b.add_file(name: '', io: File.open(__FILE__, 'rb')) - end - }.to raise_error WeTransfer::Client::Error, /400 code/ - end - end -end diff --git a/spec/features/get_board_spec.rb b/spec/features/get_board_spec.rb deleted file mode 100644 index 0b71d36..0000000 --- a/spec/features/get_board_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -require 'spec_helper' - -describe WeTransfer::Client::Boards do - let(:client) do - WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) - end - - let(:board) do - client.create_board(name: 'Test Board', description: 'Test Descritpion') - end - - describe '#get_board' do - before do - skip "this interface is still experimental" - end - - it 'it gets a exisiting board' do - client.get_board(board: board) - end - - it 'fails when no board is given' do - expect { - client.get_board - }.to raise_error ArgumentError, /board/ - end - - it 'fails when board doenst exists' do - new_board = RemoteBoard.new(id: 123456, state: 'proccessing', url: 'https://www.we.tl/123456', name: 'fake board') - expect { - client.get_board(board: new_board) - }.to raise_error WeTransfer::Client::Error, /404 code/ - end - end -end diff --git a/spec/features/transfer_spec.rb b/spec/features/transfer_spec.rb deleted file mode 100644 index 09f0e4e..0000000 --- a/spec/features/transfer_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -require 'spec_helper' - -describe WeTransfer::Client::Transfers do - let(:client) do - WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY'), logger: test_logger) - end - - let(:file_location) { fixtures_dir + 'Japan-02.jpg' } - - let(:created_transfer) do - client.create_transfer(message: 'Test transfer') do |builder| - builder.add_file(name: File.basename(file_location), io: File.open(file_location, 'rb')) - end - end - - describe "#create_transfer" do - before do - skip "this interface is still experimental" - end - - it "is needed to add a file" do - expect { client.create_transfer(message: "Transfer name") } - .to raise_error(ArgumentError, /^No files were added/) - end - - it "accepts a block to add files by their location" do - client.create_transfer(message: 'Test transfer') do |builder| - builder.add_file_at(path: file_location) - end - end - - it "accepts a block to add files by their io" do - client.create_transfer(message: 'Test transfer') do |builder| - builder.add_file(name: File.basename(file_location), io: File.open(file_location, 'rb')) - end - end - end - - describe "#complete_transfer" - describe "#get_transfer" -end diff --git a/spec/fixtures/Japan-02.jpg b/spec/fixtures/Japan-02.jpg deleted file mode 100644 index 01de2402251e448490127c019f378c914df0db09..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 200668 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�p7cNBW%>b0^d10{C9|$?iK&Wbxk~SIDs= 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

j4+ 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 oT~ApoHTxXe<}ZZF9wyg#gDUgc9(transfer) { transfer.add_file(name: 'foo', size: 8) } + add_file_lambda = ->(transfer) { transfer.add_file(name: "test file", size: 8) } expect_any_instance_of(described_class) .to receive(:persist) do |*_args, &block| @@ -56,13 +78,32 @@ end .and_call_original - described_class.create(message: 'through .create', &add_file_lambda) + described_class.create(message: "through .create", &add_file_lambda) + end + end + + describe ".find" do + it "GETs the transfer by its id" do + described_class.find("fake-transfer-id") + + expect(find_transfer_stub) + .to have_been_requested + end + + it "sets up files" do + transfer = described_class.find("fake-transfer-id") + { + id: "fake_file_id", + name: "test file", + }.each do |attr, value| + expect(transfer.files.first.send(attr)).to eq value + end end end describe "initialize" do it "passes with a message" do - expect { described_class.new(message: 'test transfer') } + expect { described_class.new(message: "test transfer") } .to_not raise_error end @@ -72,23 +113,58 @@ end end + describe "#files" do + it "is a getter for @files" do + transfer = described_class.new(message: "test transfer") + transfer.instance_variable_set :@files, "this is a fake" + + expect(transfer.files).to eq "this is a fake" + end + end + describe "#persist" do - context "adding files, using a block" do + context "adding multiple files, using a block" do + let(:files_stub) { + [ + { + id: "fake_file_id", + name: "file1", + size: 8, + multipart: { + part_numbers: 1, + chunk_size: 8 + }, + type: "file", + }, { + id: "f740463d4e995720280fa08efe1911ef20190221200022", + name: "file2", + size: 8, + multipart: { + part_numbers: 1, + chunk_size: 8 + }, + type: "file", + } + ] + } + it "works on a transfer without any files" do transfer = described_class.new(message: "test transfer") - transfer.persist { |transfer| transfer.add_file(name: 'test_file', size: 8) } + transfer.persist do |transfer| + transfer.add_file(name: "file1", size: 8) + transfer.add_file(name: "file2", size: 8) + end expect(create_transfer_stub) .to have_been_requested - end + end - it "works on a transfer that already has some files" do - transfer = described_class.new(message: 'test transfer') do |t| - t.add_file(name: 'file1', size: 8) - end + it "can be used on a transfer that already has some files" do + transfer = described_class.new(message: "test transfer") + transfer.add_file(name: "file1", size: 8) - transfer.persist { |transfer| transfer.add_file(name: 'file2', size: 8) } + transfer.persist { |transfer| transfer.add_file(name: "file2", size: 8) } expect(create_transfer_stub) .to have_been_requested @@ -97,8 +173,8 @@ context "not adding files, using a block" do it "can create a remote transfer if the transfer already had one" do - transfer = described_class.new(message: 'test transfer') - transfer.add_file(name: 'file1', size: 8) + transfer = described_class.new(message: "test transfer") + transfer.add_file(name: "test file", size: 8) transfer.persist @@ -118,41 +194,41 @@ describe "#add_file" do subject(:transfer) do - described_class.new(message: 'test transfer') do |transfer| + described_class.new(message: "test transfer") do |transfer| # this block is used to add files using # transfer.add_file(name:, size:, io:) end end - let(:file_params) { { name: :foo, size: :bar, io: :baz } } + let(:file_params) { { name: "test file", size: :bar, io: :baz } } it "can be called with only a name and a size" do - expect { transfer.add_file(name: :foo, size: :bar) } + expect { transfer.add_file(name: "test file", size: :bar) } .to_not raise_error end context "when called with only an io" do it "does work if the io provides a name and a size" do - transfer.add_file(io: File.open('Gemfile')) + transfer.add_file(io: File.open("Gemfile")) end it "doesn't work if the io has no name" do - nameless_io = StringIO.new('I have no name, but a size I have') + nameless_io = StringIO.new("I have no name, but a size I have") expect { transfer.add_file(io: nameless_io) } .to raise_error(ArgumentError, %r|name.*size.* or io should provide it|) end end it "can be called with an io, a name and a size" do - io = File.open('Gemfile') - expect { transfer.add_file(name: 'Gemfile', size: 20, io: io) } + io = File.open("Gemfile") + expect { transfer.add_file(name: "Gemfile", size: 20, io: io) } .to_not raise_error end it "does not accept duplicate file names, case insensitive" do - file_a_params = { name: 'same', size: 416 } - file_b_params = { name: 'same', size: 501 } - file_c_params = { name: 'SAME', size: 816 } + file_a_params = { name: "same", size: 416 } + file_b_params = { name: "same", size: 501 } + file_c_params = { name: "SAME", size: 816 } transfer.add_file(file_a_params) @@ -164,7 +240,7 @@ end context "WeTransferFile interaction" do - let(:fake_transfer_file) { instance_double(WeTransfer::WeTransferFile, name: 'fake') } + let(:fake_transfer_file) { instance_double(WeTransfer::WeTransferFile, name: "fake") } it "instantiates a TransferFile" do expect(WeTransfer::WeTransferFile) @@ -194,4 +270,226 @@ end end end + + describe "#upload_file" do + it "should be called with :name" do + transfer = described_class.new(message: "test transfer") + transfer.add_file(name: "test file", size: 8) + + expect { transfer.upload_file } + .to raise_error ArgumentError, %r|name| + end + + context "without io keyword param" do + it "works if the WeTransferFile instance has an io" do + transfer = described_class.new(message: "test transfer") + transfer.add_file(name: "test file", size: 8, io: StringIO.new("12345678")) + transfer.persist + + allow(transfer) + .to receive(:upload_url_for_chunk) + .with(name: "test file", chunk: kind_of(Numeric)) + .and_return("https://signed.url/123") + + stub_request(:put, "https://signed.url/123") + .to_return(status: 200, body: "", headers: {}) + + expect { transfer.upload_file(name: "test file") } + .to_not raise_error + end + + it "breaks if the WeTransferFile instance does not have an io" do + transfer = described_class.new(message: "test transfer") + transfer.add_file(name: "test file", size: 8) + + expect { transfer.upload_file(name: "test file") } + .to raise_error WeTransfer::RemoteFile::NoIoError, %r|'test file' cannot be uploaded| + end + + it "invokes :upload_url_for_chunk to obtain a PUT url" do + transfer = described_class.new(message: "test transfer") + transfer.add_file(name: "test file", size: 8, io: StringIO.new("12345678")) + transfer.persist + + stub_request(:put, "https://signed.url/123") + .to_return(status: 200, body: "", headers: {}) + + expect(transfer) + .to receive(:upload_url_for_chunk) + .with(name: "test file", chunk: kind_of(Numeric)) + .and_return("https://signed.url/123") + + transfer.upload_file(name: "test file") + end + end + + context "uploads each chunk" do + it "upload is triggered once if the io smaller than the server's chunk size" do + transfer = described_class.new(message: "test transfer") + + file_name = "test file" + contents = "12345678" + + upload_request_stub = stub_request(:put, "https://signed.url/123") + .with(body: contents) + + transfer.add_file(name: file_name, io: StringIO.new(contents)) + transfer.persist + + expect(transfer) + .to receive(:upload_url_for_chunk) + .with(name: file_name, chunk: 1) + .and_return("https://signed.url/123") + .once + + transfer.upload_file(name: file_name) + + expect(upload_request_stub).to have_been_requested + end + + it "upload is triggered twice if the io is 1.5 times the server's chunk size" do + remove_request_stub(create_transfer_stub) + + file_name = "test file" + file_size = 7_500_000 + contents = "-" * file_size + + stub_request(:post, "#{WeTransfer::CommunicationHelper::API_URL_BASE}/v2/transfers") + .to_return( + status: 200, + body: { + success: true, + id: "04a1828e9a193adacb3ea110cdcf773320190226161412", + state: "uploading", + message: "test transfer", + url: nil, + files: [ + { + id: "3a2b2c5e01657dbe09e104aa5fdd01f020190226161412", + name: file_name, + size: file_size, + multipart: { + part_numbers: 2, + chunk_size: 5242880 + }, + type: "file" + } + ], + expires_at: "2019-03-05T1614:12Z" + }.to_json, + headers: {} + ) + + transfer = described_class.new(message: "test transfer") + + expect(transfer) + .to receive(:upload_url_for_chunk) + .with(name: file_name, chunk: kind_of(Numeric)) + .and_return("https://signed.url/big-chunk-1", "https://signed.url/big-chunk-2") + + put_1 = stub_request(:put, "https://signed.url/big-chunk-1") + put_2 = stub_request(:put, "https://signed.url/big-chunk-2") + + transfer.add_file(name: file_name, size: file_size, io: StringIO.new(contents)) + transfer.persist + + transfer.upload_file(name: file_name) + expect(put_1).to have_been_requested + expect(put_2).to have_been_requested + end + end + end + + describe "#upload_url_for_chunk" do + it "does something" + end + + describe "#complete_file" do + it "works" do + transfer = described_class.new(message: "test transfer") + transfer.add_file(name: "test file", size: 8) + transfer.persist + + allow(transfer) + .to receive(:id) + .and_return("transfer_id") + + allow(transfer.files.first) + .to receive(:id) + .and_return("file_id") + + upload_complete_stub = stub_request(:put, "#{WeTransfer::CommunicationHelper::API_URL_BASE}/v2/transfers/transfer_id/files/file_id/upload-complete"). + with( + body: { part_numbers: 1 }.to_json, + ) + .to_return( + status: 200, + body: { + success: true, + id: "26a8bb18b75d8c67c744cdf3655c3fdd20190227112811", + retries: 0, + name: "test_file_1", + size: 10, + chunk_size: 5242880 + }.to_json, + headers: {} + ) + + transfer.complete_file(name: "test file") + + expect(upload_complete_stub).to have_been_requested + end + end + + describe "#finalize" do + subject(:transfer) do + WeTransfer::Transfer + .new(message: "test transfer") + .add_file(name: "test file", size: 30) + end + + let!(:finalize_transfer_stub) do + stub_request(:put, "#{WeTransfer::CommunicationHelper::API_URL_BASE}/v2/transfers/fake-transfer-id/finalize"). + to_return( + status: 200, + body: { + success: true, + id: "fake-transfer-id", + state: "processing", + message: "test transfer", + url: "https://we.tl/t-c5mHAyq1iO", + files: [ + { + id: "b40157b36830c0ca37059af5b054a45b20190225084239", + name: "test file", + size: 30, + multipart: { + part_numbers: 1, + chunk_size: 30 + }, + type: "file" + } + ], + expires_at: "2019-03-04T08:42:39Z" + }.to_json, + headers: {}, + ) + end + + before { allow(transfer).to receive(:id).and_return("fake-transfer-id") } + + it "PUTs to the finalize endpoint" do + transfer.finalize + + expect(finalize_transfer_stub).to have_been_requested + end + + it "gets a new status" do + expect(transfer.state).to be_nil + transfer.finalize + expect(transfer.state).to eq "processing" + end + + it "url?" + end end diff --git a/spec/we_transfer/we_transfer_file_spec.rb b/spec/we_transfer/we_transfer_file_spec.rb index a2c054f..4b27617 100644 --- a/spec/we_transfer/we_transfer_file_spec.rb +++ b/spec/we_transfer/we_transfer_file_spec.rb @@ -31,16 +31,32 @@ described_class.new(io: io) end - it "delegates the size to io if unavailable" do - io = File.open('Gemfile') - - expect(File) - .to receive(:basename) + it "wraps the io in MiniIO" do + io = :foo + expect(WeTransfer::MiniIO) + .to receive(:new) .with(io) - .and_return "delegated" - described_class.new(io: io) + described_class.new(io: io, name: 'test file', size: 8) + end + end + + describe "getters" do + subject { described_class.new(io: File.open('Gemfile')) } + + %i[name id io multipart].each do |getter| + it "has a getter for :#{getter}" do + expect { subject.send getter }.to_not raise_error + end + end + end + + describe "#as_request_params" do + subject(:file) { described_class.new(name: 'test file', size: 8) } + + it "has key/values for name and size only" do + expect(file.as_request_params.to_json) + .to eq %|{"name":"test file","size":8}| end - it "wraps the IO in a MiniIO" end end diff --git a/spec/we_transfer_client/board_builder_spe.rb b/spec/we_transfer_client/board_builder_spe.rb deleted file mode 100644 index be7d863..0000000 --- a/spec/we_transfer_client/board_builder_spe.rb +++ /dev/null @@ -1,66 +0,0 @@ -require 'spec_helper' - -describe BoardBuilder do - describe '#initialize' do - it 'initializes with an empty array' do - expect(subject.items.empty?).to be(true) - end - end - - describe '#add_file' do - it 'returns an error when name is missing' do - expect { - subject.add_file(io: File.open(__FILE__, 'rb')) - }.to raise_error ArgumentError, /name/ - end - - it 'returns an error when io is missing' do - expect { - subject.add_file(name: 'file name') - }.to raise_error ArgumentError, /io/ - end - - it 'returns a error when file doesnt exists' do - expect { - subject.add_file(name: 'file name', io: File.open('foo', 'rb')) - }.to raise_error Errno::ENOENT - end - - it 'adds a file when name and io is given' do - subject.add_file(name: 'file name', io: File.open(__FILE__, 'rb')) - expect(subject.items.first).to be_kind_of(FutureFile) - end - end - - describe '#add_file_at' do - before do - skip "this interface is still experimental" - end - - it 'adds a file from a path' do - subject.add_file_at(path: __FILE__) - expect(subject.items.first).to be_kind_of(FutureFile) - end - - it 'throws a Error when file doesnt exists' do - expect { - subject.add_file_at(path: '/this/path/leads/to/nothing.exe') - }.to raise_error Errno::ENOENT - end - - it 'should call #add_file' do - client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) - client.create_board(name: 'Test board', description: 'A board description') do |b| - expect(b).to receive(:add_file).with(name: anything, io: kind_of(::IO)) - b.add_file_at(path: __FILE__) - end - end - end - - describe '#add_web_url' do - it 'adds a item to board when url and title are given' do - subject.add_web_url(url: 'http://www.wetransfer.com', title: 'wetransfer') - expect(subject.items.first).to be_kind_of(FutureLink) - end - end -end diff --git a/spec/we_transfer_client/boards_spe.rb b/spec/we_transfer_client/boards_spe.rb deleted file mode 100644 index 0e09440..0000000 --- a/spec/we_transfer_client/boards_spe.rb +++ /dev/null @@ -1,56 +0,0 @@ -require 'spec_helper' - -require_relative '../../lib/we_transfer_client.rb' - -describe WeTransfer::Client::Boards do - describe '#create_board_and_upload_items' do - it 'creates a board and uploads the files' do - client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) - board = client.create_board_and_upload_items(name: 'test', description: 'test description') do |b| - b.add_file(name: File.basename(__FILE__), io: File.open(__FILE__, 'rb')) - b.add_web_url(url: 'http://www.wetransfer.com', title: 'WeTransfer Website') - end - expect(board).to be_kind_of(RemoteBoard) - expect(board.url).to start_with('https://we.tl/') - expect(board.state).to eq('downloadable') - end - end - - describe "experimental features" do - before do - skip "this interface is still experimental" - end - - describe '#create_board' do - it 'creates a board' do - client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) - board = client.create_board(name: 'test', description: 'test description') - expect(board).to be_kind_of(RemoteBoard) - end - end - - describe "#add_items" do - it 'adds items to an existing board' do - client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) - board = client.create_board(name: 'test', description: 'test description') - updated_board = client.add_items(board: board) do |b| - b.add_file(name: File.basename(__FILE__), io: File.open(__FILE__, 'rb')) - b.add_web_url(url: 'http://www.wetransfer.com', title: 'WeTransfer Website') - end - expect(updated_board).to be_kind_of(RemoteBoard) - expect(updated_board.items.size).to eq(2) - expect(updated_board.items.map(&:class)).to eq([RemoteFile, RemoteLink]) - end - end - - describe "#get_board" do - it 'gets board' do - client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) - board = client.create_board(name: 'test', description: 'test description') - board_request = client.get_board(board: board) - - expect(board_request).to be_kind_of(RemoteBoard) - end - end - end -end diff --git a/spec/we_transfer_client/future_board_spe.rb b/spec/we_transfer_client/future_board_spe.rb deleted file mode 100644 index c4902c4..0000000 --- a/spec/we_transfer_client/future_board_spe.rb +++ /dev/null @@ -1,98 +0,0 @@ -require 'spec_helper' - -describe FutureBoard do - let(:params) { { name: 'yes', description: 'A description about the board', items: [] } } - - describe '#initializer' do - it 'raises ArgumentError when no name is given' do - params.delete(:name) - expect { - described_class.new(params) - }.to raise_exception ArgumentError, /name/ - end - - it 'accepts a blank description' do - params.delete(:description) - described_class.new(params) - end - - it 'accepts a empty array as item argument' do - expect(described_class.new(params).items).to be_kind_of(Array) - end - end - - describe '#to_initial_request_params' do - it 'has a name' do - as_params = described_class.new(params).to_initial_request_params - expect(as_params[:name]).to be_kind_of(String) - end - - it 'has a description' do - as_params = described_class.new(params).to_initial_request_params - expect(as_params[:description]).to be(params[:description]) - end - end - - describe '#as_json_request_params' do - it 'has a name' do - as_params = described_class.new(params).as_json_request_params - expect(as_params[:name]).to be_kind_of(String) - end - - it 'has a description' do - as_params = described_class.new(params).as_json_request_params - expect(as_params[:description]).to be(params[:description]) - end - - it 'has items' do - file = FutureFile.new(name: 'yes', io: File.open(__FILE__, 'rb')) - params[:items] << file - as_params = described_class.new(params).as_json_request_params - expect(as_params[:items].count).to be(1) - end - end - - describe '#files' do - it 'returns only file items' do - file = FutureFile.new(name: 'yes', io: File.open(__FILE__, 'rb')) - link = FutureLink.new(url: 'https://www.wetransfer.com', title: 'WeTransfer') - future_board = described_class.new(params) - 3.times do - future_board.items << file - future_board.items << link - end - expect(future_board.items.size).to eq(6) - expect(future_board.files.size).to eq(3) - end - end - - describe '#links' do - it 'returns only link items' do - file = FutureFile.new(name: 'yes', io: File.open(__FILE__, 'rb')) - link = FutureLink.new(url: 'https://www.wetransfer.com', title: 'WeTransfer') - future_board = described_class.new(params) - 3.times do - future_board.items << file - future_board.items << link - end - expect(future_board.items.size).to eq(6) - expect(future_board.links.size).to eq(3) - end - end - - describe 'getters' do - let(:subject) { described_class.new(params) } - - it '#name' do - subject.name - end - - it '#description' do - subject.description - end - - it 'items' do - subject.items - end - end -end diff --git a/spec/we_transfer_client/future_file_spe.rb b/spec/we_transfer_client/future_file_spe.rb deleted file mode 100644 index d7faa7a..0000000 --- a/spec/we_transfer_client/future_file_spe.rb +++ /dev/null @@ -1,48 +0,0 @@ -require 'spec_helper' - -describe FutureFile do - let(:params) { { name: 'yes', io: File.open(__FILE__, 'rb') } } - - describe '#initilizer' do - it 'needs an :io keyword arg' do - params.delete(:io) - - expect { - described_class.new(params) - }.to raise_error(ArgumentError, /io/) - end - - it 'needs a :name keyword arg' do - params.delete(:name) - - expect { - described_class.new(params) - }.to raise_error(ArgumentError, /name/) - end - - it 'succeeds if given all arguments' do - described_class.new(params) - end - end - - describe '#as_json_request_params' do - it 'returns a hash with name and size' do - as_params = described_class.new(params).as_json_request_params - - expect(as_params[:name]).to eq('yes') - expect(as_params[:size]).to be_kind_of(Integer) - end - end - - describe 'getters' do - let(:subject) { described_class.new(params) } - - it '#name' do - subject.name - end - - it '#io' do - subject.io - end - end -end diff --git a/spec/we_transfer_client/future_link_spe.rb b/spec/we_transfer_client/future_link_spe.rb deleted file mode 100644 index af385c2..0000000 --- a/spec/we_transfer_client/future_link_spe.rb +++ /dev/null @@ -1,44 +0,0 @@ -require 'spec_helper' - -describe FutureLink do - let(:params) { { url: 'http://www.wetransfer.com', title: 'WeTransfer' } } - - describe '#initializer' do - it 'needs a :url keyword arg' do - params.delete(:url) - expect { - described_class.new(params) - }.to raise_error(ArgumentError, /url/) - end - - it 'takes url when no title is given' do - params.delete(:title) - expect(described_class.new(params).title).to be(params.fetch(:url)) - end - - it 'succeeds if given all arguments' do - described_class.new(params) - end - end - - describe '#as_json_request_params' do - it 'creates params properly' do - as_params = described_class.new(params).as_json_request_params - - expect(as_params[:url]).to eq('http://www.wetransfer.com') - expect(as_params[:title]).to be_kind_of(String) - end - end - - describe 'getters' do - let(:subject) { described_class.new(params) } - - it '#url' do - subject.url - end - - it '#title' do - subject.title - end - end -end diff --git a/spec/we_transfer_client/future_transfer_spe.rb b/spec/we_transfer_client/future_transfer_spe.rb deleted file mode 100644 index e3b5962..0000000 --- a/spec/we_transfer_client/future_transfer_spe.rb +++ /dev/null @@ -1,39 +0,0 @@ -require 'spec_helper' - -describe FutureTransfer do - let(:transfer) { described_class } - - describe '#initialize' do - it 'creates an empty array when initialized' do - expect(transfer.new(message: 'transfer').files).to be_kind_of(Array) - end - - it 'throws a error when message is not given' do - expect { - transfer.new - }.to raise_error ArgumentError - end - end - - describe 'getters' do - it 'message' do - transfer.new(message: 'test').message - end - - it 'files' do - transfer.new(message: 'test').files - end - end - - describe 'as_json_request_params' do - it 'includes the message' do - new_transfer = transfer.new(message: 'test') - expect(new_transfer.as_json_request_params[:message]).to be(new_transfer.message) - end - - it 'includes the files as an array' do - new_transfer = transfer.new(message: 'test') - expect(new_transfer.as_json_request_params[:files]).to eq([]) - end - end -end diff --git a/spec/we_transfer_client/remote_board_spe.rb b/spec/we_transfer_client/remote_board_spe.rb deleted file mode 100644 index 75ef24a..0000000 --- a/spec/we_transfer_client/remote_board_spe.rb +++ /dev/null @@ -1,92 +0,0 @@ - -require 'spec_helper' - -describe RemoteBoard do - subject { described_class.new(params) } - - let(:params) { - { - id: SecureRandom.uuid, - state: 'downloadable', - url: 'http://wt.tl/123abcd', - name: 'RemoteBoard', - description: 'Test Description', - items: [ - { - id: 's7l1urvgqs1b6u9v720180911093825', - name: 'board_integration_spec.rb', - size: 3036, - multipart: { - part_numbers: 1, - chunk_size: 3036 - }, - type: 'file', - }, - { - id: 'storr6ua2l1fsl8lt20180911093826', - url: 'http://www.wetransfer.com', - meta: {title: 'WeTransfer Website'}, - type: 'link', - } - ] - } - } - - describe '#initializer' do - it 'is valid with all params' do - subject - end - - it 'is valid without description' do - params.delete(:description) - subject - end - - it 'is valid without items' do - params.delete(:items) - subject - end - - %i[id name state url].each do |param| - it "is invalid without #{param}" do - params.delete(param) - expect { - subject - }.to raise_error ArgumentError, %r[#{param}] - end - end - - describe 'items' do - it 'are instantiated' do - expect(subject.items.map(&:class)).to eq([RemoteFile, RemoteLink]) - end - - it 'raises ItemTypeError if the item has a wrong type' do - params[:items] = [{ type: 'foo' }] - expect { subject }.to raise_error(RemoteBoard::ItemTypeError) - end - end - end - - describe '#files' do - it 'returns only file item' do - expect(subject.items.size).to eq(2) - expect(subject.files.size).to eq(1) - end - end - - describe '#links' do - it 'returns only link item' do - expect(subject.items.size).to eq(2) - expect(subject.links.size).to eq(1) - end - end - - describe 'getters' do - %i[id items url state].each do |getter| - it "responds to ##{getter}" do - subject.send getter - end - end - end -end diff --git a/spec/we_transfer_client/remote_file_spe.rb b/spec/we_transfer_client/remote_file_spe.rb deleted file mode 100644 index d9c11be..0000000 --- a/spec/we_transfer_client/remote_file_spe.rb +++ /dev/null @@ -1,91 +0,0 @@ -require 'spec_helper' - -describe RemoteFile do - let(:params) { - { - id: SecureRandom.uuid, - name: 'Board name', - size: Random.rand(9999999), - url: nil, - multipart: { - part_numbers: Random.rand(10), - id: SecureRandom.uuid, - chunk_size: RemoteBoard::CHUNK_SIZE, - }, - type: 'file', - }} - - describe '#initializer' do - it 'initialized when no url is given' do - params.delete(:url) - described_class.new(params) - end - - it 'initialized when no item is given' do - params.delete(:items) - described_class.new(params) - end - - it 'fails when id is missing' do - params.delete(:id) - expect { - described_class.new(params) - }.to raise_error ArgumentError, /id/ - end - - it 'fails when state is missing' do - params.delete(:size) - expect { - described_class.new(params) - }.to raise_error ArgumentError, /size/ - end - - it 'fails when url is missing' do - params.delete(:multipart) - expect { - described_class.new(params) - }.to raise_error ArgumentError, /multipart/ - end - - it 'fails when name is missing' do - params.delete(:name) - expect { - described_class.new(params) - }.to raise_error ArgumentError, /name/ - end - - it 'must have a struct in multipart' do - remote_file = described_class.new(params) - expect(remote_file.multipart).to be_kind_of(Struct) - end - - it 'multipart has partnumber, id and chunk_size keys' do - remote_file = described_class.new(params) - expect(remote_file.multipart.members).to eq(params[:multipart].keys) - end - end - - describe 'Getters' do - let(:subject) { described_class.new(params) } - - it '#multipart' do - subject.multipart - end - - it '#name' do - subject.name - end - - it '#type' do - subject.type - end - - it '#id' do - subject.id - end - - it '#url' do - subject.url - end - end -end diff --git a/spec/we_transfer_client/remote_link_spe.rb b/spec/we_transfer_client/remote_link_spe.rb deleted file mode 100644 index 883c6a5..0000000 --- a/spec/we_transfer_client/remote_link_spe.rb +++ /dev/null @@ -1,33 +0,0 @@ -require 'spec_helper' - -describe RemoteLink do - let(:params) { - { - id: SecureRandom.uuid, - url: 'http://www.wetransfer.com', - meta: {title: 'wetransfer.com'}, - type: 'link', - } - } - - describe '#initialize' do - attributes = %i[id url type meta] - - attributes.each do |atttribute| - it "raises an ArgumentError when #{atttribute} is missing" do - params.delete(atttribute) - expect { - described_class.new(params) - }.to raise_error ArgumentError, %r{#{atttribute}} - end - end - end - - describe 'getters' do - subject { described_class.new(params) } - - it 'responds to #type' do - expect(subject.type).to eq 'link' - end - end -end diff --git a/spec/we_transfer_client/remote_transfer_spe.rb b/spec/we_transfer_client/remote_transfer_spe.rb deleted file mode 100644 index 7485660..0000000 --- a/spec/we_transfer_client/remote_transfer_spe.rb +++ /dev/null @@ -1,102 +0,0 @@ -require 'spec_helper' - -describe RemoteTransfer do - let(:params) { - { - id: '2ae97886522f375c1c6696799a56f0d820180912075119', - state: 'uploading', - message: 'Test transfer', - url: nil, - success: true, - files: - [ - { - id: '5e3823ea8ad54f259c85b776eaf7086e20180912075119', - name: 'transfer_integration_spec.rb', - size: 7361, - multipart: {part_numbers: 1, chunk_size: 7361}, - type: 'file', - }, - { - id: '43b5a6323102eced46f071f2db9ec2eb20180912075119', - name: 'two_chunks', - size: 6291460, - multipart: {part_numbers: 2, chunk_size: 5242880}, - type: 'file', - } - ] - } - } - - describe '#initialize' do - it 'fails when id is missing' do - params.delete(:id) - expect { - described_class.new(params) - }.to raise_error ArgumentError, /id/ - end - - it 'fails when state is missing' do - params.delete(:state) - expect { - described_class.new(params) - }.to raise_error ArgumentError, /state/ - end - - it 'fails when message is missing' do - params.delete(:message) - expect { - described_class.new(params) - }.to raise_error ArgumentError, /message/ - end - - it 'fails when files is a string' do - params.delete(:files) - params[:files] = 'Not an array' - expect { - described_class.new(params) - }.to raise_error NoMethodError - end - - it 'fails when files is a string' do - params.delete(:files) - params[:files] = 'Not an array' - expect { - described_class.new(params) - }.to raise_error NoMethodError - end - end - - describe '#files_to_class' do - it 'creates classes of remote files' do - transfer = described_class.new(params) - expect(transfer.files.map(&:class)).to eq([RemoteFile, RemoteFile]) - end - end - - describe '#prepare_file_upload' do - pending 'it retreives the upload url' do - fail - end - end - - describe '#Getters' do - subject { described_class.new(params) } - - it '#files' do - subject.files - end - - it '#url' do - subject.url - end - - it '#state' do - subject.state - end - - it '#id' do - subject.id - end - end -end diff --git a/spec/we_transfer_client/transfer_builder_spe.rb b/spec/we_transfer_client/transfer_builder_spe.rb deleted file mode 100644 index d511daa..0000000 --- a/spec/we_transfer_client/transfer_builder_spe.rb +++ /dev/null @@ -1,58 +0,0 @@ -require 'spec_helper' - -describe TransferBuilder do - let(:transfer) { described_class.new } - - describe '#initialze' do - it 'initializes with an empty files array' do - expect(transfer.files.empty?).to be(true) - end - end - - describe '#add_file' do - it 'returns an error when name is missing' do - expect { - transfer.add_file(io: File.open(__FILE__, 'rb')) - }.to raise_error ArgumentError, /name/ - end - - it 'returns an error when io is missing' do - expect { - transfer.add_file(name: 'file name') - }.to raise_error ArgumentError, /io/ - end - - it 'returns a error when file doesnt exists' do - expect { - transfer.add_file(name: 'file name', io: File.open('foo', 'rb')) - }.to raise_error Errno::ENOENT - end - - it 'adds a file when name and io is given' do - transfer.add_file(name: 'file name', io: File.open(__FILE__, 'rb')) - expect(transfer.files.first).to be_kind_of(FutureFile) - end - end - - describe '#add_file_at' do - it 'adds a file from a path' do - transfer.add_file_at(path: __FILE__) - expect(transfer.files.first).to be_kind_of(FutureFile) - end - - it 'throws a Error when file doesnt exists' do - expect { - transfer.add_file_at(path: '/this/path/leads/to/nothing.exe') - }.to raise_error Errno::ENOENT - end - - pending 'should call #add_file' do - skip "Lets not trigger status:400 errors" - client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY'), logger: test_logger) - client.create_transfer(message: 'A transfer message') do |builder| - expect(builder).to receive(:add_file).with(name: kind_of(String), io: kind_of(::File)) - builder.add_file_at(path: __FILE__) - end - end - end -end diff --git a/spec/we_transfer_client/transfers_spe.rb b/spec/we_transfer_client/transfers_spe.rb deleted file mode 100644 index 53c83e6..0000000 --- a/spec/we_transfer_client/transfers_spe.rb +++ /dev/null @@ -1,45 +0,0 @@ -require 'spec_helper' - -describe WeTransfer::Client::Transfers do - describe '#create_transfer_and_upload_files' do - it 'creates a transfer and uploads the files' do - client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) - transfer = client.create_transfer_and_upload_files(message: 'test description') do |b| - b.add_file(name: File.basename(__FILE__), io: File.open(__FILE__, 'rb')) - b.add_file_at(path: fixtures_dir + 'Japan-01.jpg') - end - - expect(transfer).to be_kind_of(RemoteTransfer) - expect(transfer.url).to start_with('https://we.tl/') - - transfer = loop do - res = client.get_transfer(transfer_id: transfer.id) - break res if res.state != 'processing' - sleep 1 - end - - expect(transfer.state).to eq('downloadable') - end - - it 'fails when no files are added' do - client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) - expect { - client.create_transfer_and_upload_files(message: 'test description') - }.to raise_error ArgumentError, /No files/ - end - - it 'fails with duplicate file names' do - client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY', logger: test_logger)) - expect { - client.create_transfer_and_upload_files(message: 'All the (same) Things') do |builder| - builder.add_file(name: 'README.txt', io: StringIO.new("A thing")) - builder.add_file(name: 'README.txt', io: StringIO.new("another thing")) - end - }.to raise_error WeTransfer::Client::Error - end - end - - pending '#create_transfers' - pending "#complete_transfer" - pending "#get_transfer" -end diff --git a/spec/we_transfer_client_spec.rb b/spec/we_transfer_client_spec.rb index 26c4e51..3bdf4ca 100644 --- a/spec/we_transfer_client_spec.rb +++ b/spec/we_transfer_client_spec.rb @@ -16,6 +16,9 @@ end it "instantiates a Transfer" do + allow(transfer) + .to receive(:persist) + expect(WeTransfer::Transfer) .to receive(:new) .with(message: 'fake transfer') @@ -25,38 +28,52 @@ end it "stores the transfer in @transfer" do + allow(transfer) + .to receive(:persist) + allow(WeTransfer::Transfer) .to receive(:new) .and_return(transfer) - subject.create_transfer(message: 'foo') + subject.create_transfer(message: 'test transfer') expect(subject.instance_variable_get(:@transfer)).to eq transfer end it "accepts a block, that is passed to the Transfer instance" do allow(WeTransfer::Transfer) - .to receive(:new) { |&transfer| transfer.call(name: 'meh') } - .with(message: "foo") + .to receive(:new) + .with(message: 'test transfer') .and_return(transfer) - expect { |probe| subject.create_transfer(message: 'foo', &probe) }.to yield_with_args(name: 'meh') + expect(transfer) + .to receive(:persist) { |&transfer| transfer.call(name: 'test file', size: 8) } + + expect { |probe| subject.create_transfer(message: 'test transfer', &probe) } + .to yield_with_args(name: 'test file', size: 8) end it "returns self" do + allow(transfer) + .to receive(:persist) + allow(WeTransfer::Transfer) .to receive(:new) .and_return(transfer) - expect(subject.create_transfer(message: 'foo')).to eq subject + + expect(subject.create_transfer(message: 'test transfer')).to eq subject end end - describe "integrations" do - it "works" do - client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) - transfer = client.create_transfer(message: 'test transfer') do |transfer| - transfer.add_file(name: 'test_file', size: 30) - end - # binding.pry + describe "#find_transfer" do + it "delegates to Transfer.find" do + transfer_id = 'fake-transfer-id' + expect(WeTransfer::Transfer) + .to receive(:find) + .with(transfer_id) + + subject.find_transfer(transfer_id) end + + it "stores the found transfer in @transfer" end end diff --git a/wetransfer.gemspec b/wetransfer.gemspec index 5afff79..1f54df4 100644 --- a/wetransfer.gemspec +++ b/wetransfer.gemspec @@ -1,7 +1,7 @@ lib = File.expand_path('../lib', __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require 'we_transfer_client/version' +require 'we_transfer/version' Gem::Specification.new do |spec| spec.name = 'wetransfer' @@ -23,12 +23,14 @@ Gem::Specification.new do |spec| 'public gem pushes.' end + # TODO: Only add the needed files. see https://github.com/thoughtbot/shoulda-matchers/pull/1180 for inspiration spec.files = `git ls-files -z`.split("\x0").reject { |f| f =~ /^spec/ } - spec.bindir = 'exe' - spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + # spec.bindir = 'exe' + # spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] spec.add_dependency 'faraday', '~> 0.12' + spec.add_dependency 'ks', '~> 0.0.2' spec.add_development_dependency 'bundler', '~> 1.16' spec.add_development_dependency 'dotenv', '~> 2.0' @@ -41,8 +43,3 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'webmock' spec.add_development_dependency 'wetransfer_style', '0.6.4' end - -# spec.add_development_dependency 'guard-flay' -# spec.add_development_dependency 'guard-flog' -# spec.add_development_dependency 'flay', '~> 2.4' -# spec.add_development_dependency 'flog' From 8138841c669399f5153d54dae002b4259c17cff9 Mon Sep 17 00:00:00 2001 From: Arno Date: Sat, 2 Mar 2019 15:27:15 +0100 Subject: [PATCH 03/18] Rename communication module Start documenting the behavior better --- .travis.yml | 3 +-- README.md | 15 ++++++------ bin/console | 2 +- examples/create_collection.rb | 2 +- examples/create_transfer.rb | 2 +- lib/we_transfer.rb | 12 ++++++++++ .../client.rb} | 23 ++++++++----------- ...mmunication_helper.rb => communication.rb} | 2 +- lib/we_transfer/transfer.rb | 12 +++++----- spec/spec_helper.rb | 2 +- spec/we_transfer/communication_helper_spec.rb | 4 ++-- spec/we_transfer/transfer_spec.rb | 12 +++++----- 12 files changed, 49 insertions(+), 42 deletions(-) create mode 100644 lib/we_transfer.rb rename lib/{we_transfer_client.rb => we_transfer/client.rb} (66%) rename lib/we_transfer/{communication_helper.rb => communication.rb} (99%) diff --git a/.travis.yml b/.travis.yml index 748429c..f59f293 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,5 @@ matrix: allow_failures: - rvm: jruby-9.0 script: - - 'if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then bundle exec rspec --exclude-pattern "spec/integration_spec.rb"; fi' - - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then bundle exec rspec; fi' + - RUBYOPT="--enable-frozen-string-literal" bundle exec rspec - bundle exec rubocop -c .rubocop.yml --force-exclusion diff --git a/README.md b/README.md index 7547066..1f5d7df 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ For your API key and additional info please visit our [developer portal](https:/ Add this line to your application's Gemfile: ```ruby -gem 'wetransfer', version: '0.9.0.beta2' +gem 'wetransfer', version: '0.9.0.beta3' ``` And then execute: @@ -57,7 +57,7 @@ Open the file in your text editor and add this line: WT_API_KEY= -Make sure to replace `` by your actual api key. Don't include the pointy brackets! +Make sure to replace `` with your actual api key. Don't include the pointy brackets! Great! Now you can go to your project file and use the client. @@ -66,8 +66,8 @@ Great! Now you can go to your project file and use the client. A transfer is a collection of files that can be created once, and downloaded until it expires. Once a transfer is ready for sharing, it is closed for modifications. ```ruby -# In your project file: -require 'we_transfer_client' +# In your proje ct file: +require 'we_transfer' client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) ``` @@ -97,7 +97,7 @@ Boards need a WeTransfer Client to be present, just like transfers. ```ruby # In your project file: -require 'we_transfer_client' +require 'we_transfer' client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) ``` @@ -106,7 +106,6 @@ After you create your client, you can ### Create a board and upload items - ```ruby board = client.create_board(name: 'Meow', description: 'On Cats') do |items| items.add_file(name: 'big file.jpg', io: File.open('/path/to/huge_file.jpg', 'rb')items.add_file_at(path: '/path/to/another/file.txt') @@ -150,7 +149,7 @@ Hooray! ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/wetransfer/wetransfer_ruby_sdk. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. More extensive contribution guidelines can be found [here](https://github.com/WeTransfer/wetransfer_ruby_sdk/blob/master/.github/CONTRIBUTING.md). +Bug reports and pull requests are welcome on GitHub at This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. More extensive contribution guidelines can be found [here](https://github.com/WeTransfer/wetransfer_ruby_sdk/blob/master/.github/CONTRIBUTING.md). ## License @@ -158,4 +157,4 @@ The gem is available as open source under the terms of the [MIT License](https:/ ## Code of Conduct -Everyone interacting in the WeTransfer Ruby SDK project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/WeTransfer/wetransfer_ruby_sdk/blob/master/.github/CODE_OF_CONDUCT.md). +Everyone interacting in the WeTransfer Ruby SDK project’s code bases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/WeTransfer/wetransfer_ruby_sdk/blob/master/.github/CODE_OF_CONDUCT.md). diff --git a/bin/console b/bin/console index 47e3383..7b440e3 100755 --- a/bin/console +++ b/bin/console @@ -1,7 +1,7 @@ #!/usr/bin/env ruby require 'bundler/setup' -require 'we_transfer_client' +require 'we_transfer' # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. diff --git a/examples/create_collection.rb b/examples/create_collection.rb index 2596d38..bbcf629 100644 --- a/examples/create_collection.rb +++ b/examples/create_collection.rb @@ -1,4 +1,4 @@ -require_relative 'we_transfer_client' +require_relative 'we_transfer' require 'dotenv' Dotenv.load diff --git a/examples/create_transfer.rb b/examples/create_transfer.rb index 2596d38..bbcf629 100644 --- a/examples/create_transfer.rb +++ b/examples/create_transfer.rb @@ -1,4 +1,4 @@ -require_relative 'we_transfer_client' +require_relative 'we_transfer' require 'dotenv' Dotenv.load diff --git a/lib/we_transfer.rb b/lib/we_transfer.rb new file mode 100644 index 0000000..a6f7c46 --- /dev/null +++ b/lib/we_transfer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'faraday' +require 'logger' +require 'json' +require 'ks' + +%w[communication_helper client transfer mini_io we_transfer_file remote_file version].each do |file| + require_relative "we_transfer/#{file}" +end + +module WeTransfer; end diff --git a/lib/we_transfer_client.rb b/lib/we_transfer/client.rb similarity index 66% rename from lib/we_transfer_client.rb rename to lib/we_transfer/client.rb index 6561321..701b473 100644 --- a/lib/we_transfer_client.rb +++ b/lib/we_transfer/client.rb @@ -1,18 +1,9 @@ # frozen_string_literal: true -require 'faraday' -require 'logger' -require 'json' -require 'ks' - -%w[communication_helper transfer mini_io we_transfer_file remote_file version].each do |file| - require_relative "we_transfer/#{file}" -end - module WeTransfer class Client class Error < StandardError; end - include CommunicationHelper + include Communication NullLogger = Logger.new(nil) @@ -25,9 +16,9 @@ class Error < StandardError; end # # @return [WeTransfer::Client] def initialize(api_key:, logger: NullLogger) - CommunicationHelper.reset_authentication! - CommunicationHelper.api_key = api_key - CommunicationHelper.logger = logger + Communication.reset_authentication! + Communication.api_key = api_key + Communication.logger = logger end def create_transfer(**args, &block) @@ -38,6 +29,12 @@ def create_transfer(**args, &block) self end + def create_transfer_and_upload_files(message:, &block) + transfer = WeTransfer::Transfer.new(args) + transfer.persist(&block) + + end + def find_transfer(transfer_id) @transfer = WeTransfer::Transfer.find(transfer_id) end diff --git a/lib/we_transfer/communication_helper.rb b/lib/we_transfer/communication.rb similarity index 99% rename from lib/we_transfer/communication_helper.rb rename to lib/we_transfer/communication.rb index 68c3eb4..47062d4 100644 --- a/lib/we_transfer/communication_helper.rb +++ b/lib/we_transfer/communication.rb @@ -1,7 +1,7 @@ module WeTransfer class CommunicationError < StandardError; end - module CommunicationHelper + module Communication extend Forwardable API_URL_BASE = "https://dev.wetransfer.com" diff --git a/lib/we_transfer/transfer.rb b/lib/we_transfer/transfer.rb index 0d8b26d..c67e654 100644 --- a/lib/we_transfer/transfer.rb +++ b/lib/we_transfer/transfer.rb @@ -10,7 +10,7 @@ class FileMismatchError < StandardError; end class << self extend Forwardable - def_delegator CommunicationHelper, :find_transfer, :find + def_delegator Communication, :find_transfer, :find end def self.create(message:, &block) @@ -29,7 +29,7 @@ def persist yield(self) if block_given? raise NoFilesAddedError if @unique_file_names.empty? - CommunicationHelper.persist_transfer(self) + Communication.persist_transfer(self) end # Add one or more files to a transfer, so a transfer can be created over the @@ -60,22 +60,22 @@ def upload_file(name:, io: nil) chunk_contents = StringIO.new(put_io.read(file.multipart.chunk_size)) chunk_contents.rewind - CommunicationHelper.upload_chunk(put_url, chunk_contents) + Communication.upload_chunk(put_url, chunk_contents) end end def upload_url_for_chunk(name:, chunk:) file_id = find_file_by_name(name).id - CommunicationHelper.upload_url_for_chunk(id, file_id, chunk) + Communication.upload_url_for_chunk(id, file_id, chunk) end def complete_file(name:) file = find_file_by_name(name) - CommunicationHelper.complete_file(id, file.id, file.multipart.chunks) + Communication.complete_file(id, file.id, file.multipart.chunks) end def finalize - CommunicationHelper.finalize_transfer(self) + Communication.finalize_transfer(self) end def as_request_params diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ee5e8bd..8a297ae 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,7 +5,7 @@ add_filter '/spec/' end -require 'we_transfer_client' +require 'we_transfer' require 'pry' require 'rspec' require 'bundler' diff --git a/spec/we_transfer/communication_helper_spec.rb b/spec/we_transfer/communication_helper_spec.rb index 8c2c1a2..9ad9212 100644 --- a/spec/we_transfer/communication_helper_spec.rb +++ b/spec/we_transfer/communication_helper_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe WeTransfer::CommunicationHelper do +describe WeTransfer::Communication do context ".ensure_ok_status!" do subject { described_class } @@ -8,7 +8,7 @@ before(:all) do Response = Struct.new(:status, :body) - WeTransfer::CommunicationHelper.logger = Logger.new(nil) + WeTransfer::Communication.logger = Logger.new(nil) end context "on success" do diff --git a/spec/we_transfer/transfer_spec.rb b/spec/we_transfer/transfer_spec.rb index 4f51043..86f86c9 100644 --- a/spec/we_transfer/transfer_spec.rb +++ b/spec/we_transfer/transfer_spec.rb @@ -2,12 +2,12 @@ describe WeTransfer::Transfer do let!(:authentication_stub) { - stub_request(:post, "#{WeTransfer::CommunicationHelper::API_URL_BASE}/v2/authorize") + stub_request(:post, "#{WeTransfer::Communication::API_URL_BASE}/v2/authorize") .to_return(status: 200, body: {token: "fake-test-token"}.to_json, headers: {}) } let!(:create_transfer_stub) do - stub_request(:post, "#{WeTransfer::CommunicationHelper::API_URL_BASE}/v2/transfers"). + stub_request(:post, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers"). to_return( status: 200, body: { @@ -24,7 +24,7 @@ end let!(:find_transfer_stub) do - stub_request(:get, "#{WeTransfer::CommunicationHelper::API_URL_BASE}/v2/transfers/fake-transfer-id"). + stub_request(:get, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id"). to_return( status: 200, body: { @@ -354,7 +354,7 @@ file_size = 7_500_000 contents = "-" * file_size - stub_request(:post, "#{WeTransfer::CommunicationHelper::API_URL_BASE}/v2/transfers") + stub_request(:post, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers") .to_return( status: 200, body: { @@ -418,7 +418,7 @@ .to receive(:id) .and_return("file_id") - upload_complete_stub = stub_request(:put, "#{WeTransfer::CommunicationHelper::API_URL_BASE}/v2/transfers/transfer_id/files/file_id/upload-complete"). + upload_complete_stub = stub_request(:put, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/transfer_id/files/file_id/upload-complete"). with( body: { part_numbers: 1 }.to_json, ) @@ -449,7 +449,7 @@ end let!(:finalize_transfer_stub) do - stub_request(:put, "#{WeTransfer::CommunicationHelper::API_URL_BASE}/v2/transfers/fake-transfer-id/finalize"). + stub_request(:put, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id/finalize"). to_return( status: 200, body: { From bcf0a45d0e48720f024a538ad364015264c3c3c8 Mon Sep 17 00:00:00 2001 From: Arno Date: Sat, 2 Mar 2019 16:07:43 +0100 Subject: [PATCH 04/18] Enable more jrubies in CI --- .travis.yml | 8 ++++++-- lib/we_transfer.rb | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index f59f293..8ae0165 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,12 +5,16 @@ rvm: - 2.4 - 2.5 - 2.6 -- jruby-9.0 +- jruby-9.0.5 +- jruby-9.1.6 +- jruby-9.2.0 before_install: echo Y | rvm @global do gem uninstall bundler ; gem install bundler -v 1.16.1 cache: bundler matrix: allow_failures: - - rvm: jruby-9.0 + - rvm: jruby-9.0.5 + - rvm: jruby-9.1.6 + - rvm: jruby-9.2.0 script: - RUBYOPT="--enable-frozen-string-literal" bundle exec rspec - bundle exec rubocop -c .rubocop.yml --force-exclusion diff --git a/lib/we_transfer.rb b/lib/we_transfer.rb index a6f7c46..e316b7c 100644 --- a/lib/we_transfer.rb +++ b/lib/we_transfer.rb @@ -5,7 +5,7 @@ require 'json' require 'ks' -%w[communication_helper client transfer mini_io we_transfer_file remote_file version].each do |file| +%w[communication client transfer mini_io we_transfer_file remote_file version].each do |file| require_relative "we_transfer/#{file}" end From 963ea5560a24cfe6c0a02dca77dc8a2b4878cb3a Mon Sep 17 00:00:00 2001 From: Arno Date: Sat, 2 Mar 2019 21:05:47 +0100 Subject: [PATCH 05/18] Let's not try to be ready for frozen strings --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 8ae0165..dabf05b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,5 +16,6 @@ matrix: - rvm: jruby-9.1.6 - rvm: jruby-9.2.0 script: - - RUBYOPT="--enable-frozen-string-literal" bundle exec rspec + - 'if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then bundle exec rspec --exclude-pattern "spec/integration_spec.rb"; fi' + - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then bundle exec rspec; fi' - bundle exec rubocop -c .rubocop.yml --force-exclusion From e4b1e7b7e85b25140f3a129b1f0d4283b312fec1 Mon Sep 17 00:00:00 2001 From: Arno Date: Sat, 2 Mar 2019 21:25:48 +0100 Subject: [PATCH 06/18] Implement create_transfer_and_upload_files --- README.md | 22 ++++++++++---- lib/we_transfer/client.rb | 11 +++---- lib/we_transfer/transfer.rb | 29 +++++++++++++++---- .../client_creates_a_transfer_spec.rb | 22 ++++++++++---- 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1f5d7df..d3d313b 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,26 @@ client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) Now that you've got the client set up you can use `create_transfer_and_upload_files` to, well, create a transfer, and upload all files! ```ruby -transfer = client.create_transfer_and_upload_files(message: 'All the Things') do |upload| - upload.add_file_at(path: '/path/to/local/file.jpg') - upload.add_file_at(path: '/path/to/another/local/file.jpg') - upload.add_file(name: 'README.txt', io: StringIO.new("You should read All the Things!")) +client.create_transfer_and_upload_files(message: 'All the Things') do |transfer| + transfer.add_file(io: File.open('Gemfile')) + transfer.add_file( + name: 'hello_world.rb', + io: File.open('path/to/different_name.rb') + ) + transfer.add_file( + name: 'README.txt', + size: 31, + io: StringIO.new("You should read All the Things!") + ) end +transfer = client.transfer + # To get a link to your transfer, call `url` on your transfer object: -transfer.url => "https://we.tl/t-123234=" +transfer.url => "https://we.tl/t-1232346" + +# Or inspect the whole transfer: +puts transfer.to_json ``` The upload will be performed at the end of the block. Depending on your file sizes and network connection speed, this might take some time. diff --git a/lib/we_transfer/client.rb b/lib/we_transfer/client.rb index 701b473..95b2a4f 100644 --- a/lib/we_transfer/client.rb +++ b/lib/we_transfer/client.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module WeTransfer class Client class Error < StandardError; end @@ -29,10 +27,13 @@ def create_transfer(**args, &block) self end - def create_transfer_and_upload_files(message:, &block) - transfer = WeTransfer::Transfer.new(args) - transfer.persist(&block) + def create_transfer_and_upload_files(**args, &block) + create_transfer(args, &block) + @transfer.upload_files + @transfer.complete_files + @transfer.finalize + self end def find_transfer(transfer_id) diff --git a/lib/we_transfer/transfer.rb b/lib/we_transfer/transfer.rb index c67e654..b7674c9 100644 --- a/lib/we_transfer/transfer.rb +++ b/lib/we_transfer/transfer.rb @@ -46,8 +46,8 @@ def add_file(**args) self end - def upload_file(name:, io: nil) - file = find_file_by_name(name) + def upload_file(name:, io: nil, file: nil) + file ||= find_file_by_name(name) put_io = io || file.io raise( @@ -64,16 +64,35 @@ def upload_file(name:, io: nil) end end + # + def upload_files + files.each do |file| + upload_file( + name: file.name, + file: file + ) + end + end + def upload_url_for_chunk(name:, chunk:) file_id = find_file_by_name(name).id Communication.upload_url_for_chunk(id, file_id, chunk) end - def complete_file(name:) - file = find_file_by_name(name) + def complete_file(name:, file: nil) + file ||= find_file_by_name(name) Communication.complete_file(id, file.id, file.multipart.chunks) end + def complete_files + files.each do |file| + complete_file( + name: file.name, + file: file + ) + end + end + def finalize Communication.finalize_transfer(self) end @@ -106,7 +125,5 @@ def find_file_by_name(name) raise FileMismatchError unless @found_files[name] @found_files[name] end - - def_delegator self, :remote_transfer_params end end diff --git a/spec/features/client_creates_a_transfer_spec.rb b/spec/features/client_creates_a_transfer_spec.rb index 6370152..435f136 100644 --- a/spec/features/client_creates_a_transfer_spec.rb +++ b/spec/features/client_creates_a_transfer_spec.rb @@ -1,15 +1,29 @@ require "spec_helper" describe "transfer integration" do - around do |example| + around(:each) do |example| WebMock.allow_net_connect! example.run WebMock.disable_net_connect! end - it "Create a client, a transfer, it uploads files and finalizes the transfer" do - client = WeTransfer::Client.new(api_key: ENV.fetch("WT_API_KEY")) + let(:client) { WeTransfer::Client.new(api_key: ENV.fetch("WT_API_KEY")) } + + it "Convenience method create_transfer_and_upload_files does it all!" do + client.create_transfer_and_upload_files(message: "test transfer") do |transfer| + transfer.add_file(name: "small_file_with_io", size: 10, io: StringIO.new("#" * 10)) + end + transfer = client.transfer + expect(transfer.files.map(&:name)) + .to include("small_file_with_io") + expect(transfer.state) + .to eq "processing" + expect(transfer.url) + .to match %r|https://we.tl/t-| + end + + it "Create a client, a transfer, it uploads files and finalizes the transfer" do client.create_transfer(message: "test transfer") do |transfer| transfer.add_file(name: "small_file", size: 80) transfer.add_file(name: "small_file_with_io", size: 10, io: StringIO.new("#" * 10)) @@ -18,8 +32,6 @@ transfer = client.transfer - transfer.to_json - expect(transfer.url) .to be_nil From 73bfb10cc3f107c463c484c856dc740ebf62de5b Mon Sep 17 00:00:00 2001 From: Arno Date: Mon, 4 Mar 2019 21:56:22 +0100 Subject: [PATCH 07/18] Use Forwardable over silly method defs that do just that --- lib/we_transfer/mini_io.rb | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/lib/we_transfer/mini_io.rb b/lib/we_transfer/mini_io.rb index f7f50ff..dfdb1c6 100644 --- a/lib/we_transfer/mini_io.rb +++ b/lib/we_transfer/mini_io.rb @@ -2,8 +2,9 @@ module WeTransfer # Wrapper around an IO object, delegating only methods we need for creating # and sending a file in chunks. # - class MiniIO + extend Forwardable + def self.mini_io_able?(io) return false if io.is_a? WeTransfer::NullMiniIO @@ -38,28 +39,6 @@ def initialize(io) @io.rewind end - def read(*args) - @io.read(*args) - end - - def rewind - @io.rewind - end - - def seek(*args) - @io.seek(*args) - end - - # The size, delegated to io. - # - # nil is fine, since this method is used only as the default size for a - # WeTransferFile - # @returns [Integer] the size of the io - # - def size - @io.size - end - # The name of the io, guessed using File.basename. If this raises a TypeError # we swallow the error, since this is used only as the default name for a # WeTransferFile @@ -70,6 +49,8 @@ def name # yeah, what? end + def_delegators :@io, :read, :rewind, :seek, :size + private def ensure_mini_io_able!(io) From f2725906ac221fad93a7dd56543af4cceb43cf3d Mon Sep 17 00:00:00 2001 From: Arno Date: Mon, 4 Mar 2019 21:58:10 +0100 Subject: [PATCH 08/18] Spec files', multiparts' #to_h and transfers' #to_h and #to_json --- lib/we_transfer/remote_file.rb | 8 +- spec/we_transfer/communication_spec.rb | 120 ++++++++++++++++++++++ spec/we_transfer/transfer_spec.rb | 69 +++++++++++++ spec/we_transfer/we_transfer_file_spec.rb | 41 ++++++++ 4 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 spec/we_transfer/communication_spec.rb diff --git a/lib/we_transfer/remote_file.rb b/lib/we_transfer/remote_file.rb index 1017efd..dc241a9 100644 --- a/lib/we_transfer/remote_file.rb +++ b/lib/we_transfer/remote_file.rb @@ -3,13 +3,7 @@ module RemoteFile class FileMismatchError < StandardError; end class NoIoError < StandardError; end - class Multipart < ::Ks.strict(:chunks, :chunk_size) - def to_h - %i[chunks chunk_size].each_with_object({}) do |prop, memo| - memo[prop] = send(prop) - end - end - end + class Multipart < ::Ks.strict(:chunks, :chunk_size); end def self.upgrade(files_response:, transfer:) files_response.each do |file_response| diff --git a/spec/we_transfer/communication_spec.rb b/spec/we_transfer/communication_spec.rb new file mode 100644 index 0000000..f6bd3a9 --- /dev/null +++ b/spec/we_transfer/communication_spec.rb @@ -0,0 +1,120 @@ +require 'spec_helper' + +describe WeTransfer::Communication do + before { described_class.logger = Logger.new(nil) } + + # In this test we use the call to #find_transfer. This is NOT exemplary, it serves only + # the purpose of doing a request/response cycle to handle specific status codes (2xx, 4xx, 50x) + context ".ensure_ok_status!" do + + let!(:authentication_stub) { + stub_request(:post, "#{WeTransfer::Communication::API_URL_BASE}/v2/authorize") + .to_return(status: 200, body: {token: "fake-test-token"}.to_json, headers: {}) + } + + context "on a successful request" do + let!(:find_transfer_stub) do + stub_request(:get, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id"). + to_return( + status: 200, + body: { + id: "24cd3f4ccf15232e5660052a3688c03f20190221200022", + state: "uploading", + message: "test transfer", + url: nil, + files: [ + { + id: "fake_file_id", + name: "test file", + size: 8, + multipart: { + part_numbers: 1, + chunk_size: 8 + }, + type: "file", + } + ], + expires_at: "2019-02-28T20:00:22Z", + }.to_json, + headers: {}, + ) + end + + it "does not raise a CommunicationError" do + expect { described_class.find_transfer('fake-transfer-id') } + .to_not raise_error(WeTransfer::CommunicationError) + end + end + + context "on a client error" do + let!(:find_transfer_stub) do + stub_request(:get, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id"). + to_return( + status: 405, + body: '{ + "success":false, + "message":"fake message" + }', + headers: {}, + ) + end + + it "raises a CommunicationError" do + expect { described_class.find_transfer('fake-transfer-id') } + .to raise_error(WeTransfer::CommunicationError) + end + + it "showing the error from the API to the user" do + expect { described_class.find_transfer('fake-transfer-id') } + .to raise_error('fake message') + end + end + + context "on a server error" do + let!(:find_transfer_stub) do + stub_request(:get, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id"). + to_return( + status: 501, + body: '', + headers: {}, + ) + end + + it "raises a CommunicationError" do + expect { described_class.find_transfer('fake-transfer-id') } + .to raise_error(WeTransfer::CommunicationError) + end + + it "is telling we can try again" do + expect { described_class.find_transfer('fake-transfer-id') } + .to raise_error(%r|had a 501 code.*could retry|) + end + + context "everything else" do + let(:find_transfer_stub) do + stub_request(:get, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id"). + to_return( + status: 302, + body: '', + headers: {}, + ) + end + + it "raises a CommunicationError" do + expect { described_class.find_transfer('fake-transfer-id') } + .to raise_error(WeTransfer::CommunicationError) + end + + it "includes the error code in the message" do + expect { described_class.find_transfer('fake-transfer-id') } + .to raise_error(%r|had a 302 code|) + end + + it "informs the user we have no way how to continue" do + expect { described_class.find_transfer('fake-transfer-id') } + .to raise_error(%r|no idea what to do with that|) + end + end + end + end +end diff --git a/spec/we_transfer/transfer_spec.rb b/spec/we_transfer/transfer_spec.rb index 86f86c9..d8cf428 100644 --- a/spec/we_transfer/transfer_spec.rb +++ b/spec/we_transfer/transfer_spec.rb @@ -492,4 +492,73 @@ it "url?" end + + describe "#to_h" do + let(:transfer) { described_class.new(message: 'test transfer') } + let(:file_stub_1) { [instance_double(WeTransfer::WeTransferFile, name: 'foo', size: 8)] } + + it "has keys and values for id, state, url, message and files" do + allow(file_stub_1) + .to receive(:to_h) + .and_return('fake-file-to_h') + + allow(transfer) + .to receive(:files) + .and_return([file_stub_1]) + + allow(transfer) + .to receive(:id) + .and_return('fake-id') + + allow(transfer) + .to receive(:state) + .and_return('fake-state') + + allow(transfer) + .to receive(:url) + .and_return('fake-url') + + expected = { + id: "fake-id", + state: "fake-state", + url: "fake-url", + message: "test transfer", + files: ["fake-file-to_h"] + } + + expect(transfer.to_h) + .to match(expected) + end + + it "calls :to_h on all files" do + file_stub_2 = instance_double(WeTransfer::WeTransferFile, name: 'bar', size: 8) + + allow(transfer) + .to receive(:files) + .and_return([file_stub_1, file_stub_2]) + + expect(file_stub_1) + .to receive(:to_h) + + expect(file_stub_2) + .to receive(:to_h) + + transfer.to_h + end + end + + describe "#to_json" do + it "converts the results of #to_h" do + transfer = described_class.new(message: 'test transfer') + + transfer_hash = { "foo" => "bar" } + + allow(transfer) + .to receive(:to_h) + .and_return(transfer_hash) + + expect(JSON.parse(transfer.to_json)) + .to eq(transfer_hash) + end + end end diff --git a/spec/we_transfer/we_transfer_file_spec.rb b/spec/we_transfer/we_transfer_file_spec.rb index 4b27617..45bed57 100644 --- a/spec/we_transfer/we_transfer_file_spec.rb +++ b/spec/we_transfer/we_transfer_file_spec.rb @@ -59,4 +59,45 @@ .to eq %|{"name":"test file","size":8}| end end + + describe "#to_h" do + let(:file) { described_class.new(name: 'foo', size: 8) } + let(:multipart_stub) { instance_double(WeTransfer::RemoteFile::Multipart) } + + it "has keys and values for id, name, size and multipart" do + allow(file) + .to receive(:multipart) + .and_return(multipart_stub) + + allow(file) + .to receive(:id) + .and_return('fake-id') + + allow(multipart_stub) + .to receive(:to_h) + .and_return('fake-multipart') + + expected = { + id: 'fake-id', + multipart: 'fake-multipart', + size: 8, + name: 'foo' + + } + + expect(file.to_h) + .to match(expected) + end + + it "calls :to_h on multipart" do + allow(file) + .to receive(:multipart) + .and_return(multipart_stub) + + expect(multipart_stub) + .to receive(:to_h) + + file.to_h + end + end end From 6d1f23942c877631b27b0606ce11dbf034a8ecab Mon Sep 17 00:00:00 2001 From: Arno Date: Wed, 6 Mar 2019 22:07:53 +0100 Subject: [PATCH 09/18] Make Communication class safer by not storing state inside some singleton Move specs around --- README.md | 15 +- lib/we_transfer.rb | 28 +- lib/we_transfer/client.rb | 29 +- lib/we_transfer/communication.rb | 305 ++++++---- lib/we_transfer/logging.rb | 7 + lib/we_transfer/transfer.rb | 39 +- lib/we_transfer/we_transfer_file.rb | 2 +- .../client_creates_a_transfer_spec.rb | 7 +- spec/we_transfer/client_spec.rb | 102 ++++ spec/we_transfer/communication_helper_spec.rb | 57 -- spec/we_transfer/communication_spec.rb | 312 +++++++++- spec/we_transfer/transfer_spec.rb | 565 ++++++++++-------- spec/we_transfer/we_transfer_file_spec.rb | 4 +- spec/we_transfer_client_spec.rb | 79 --- 14 files changed, 979 insertions(+), 572 deletions(-) create mode 100644 lib/we_transfer/logging.rb create mode 100644 spec/we_transfer/client_spec.rb delete mode 100644 spec/we_transfer/communication_helper_spec.rb diff --git a/README.md b/README.md index d3d313b..a3a19b5 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Great! Now you can go to your project file and use the client. A transfer is a collection of files that can be created once, and downloaded until it expires. Once a transfer is ready for sharing, it is closed for modifications. ```ruby -# In your proje ct file: +# In your project file: require 'we_transfer' client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) @@ -75,12 +75,21 @@ client = WeTransfer::Client.new(api_key: ENV.fetch('WT_API_KEY')) Now that you've got the client set up you can use `create_transfer_and_upload_files` to, well, create a transfer, and upload all files! ```ruby -client.create_transfer_and_upload_files(message: 'All the Things') do |transfer| +transfer = client.create_transfer_and_upload_files(message: 'All the Things') do |transfer| + # Add a file using File.open. If you do it like this, :name and :io params are optional transfer.add_file(io: File.open('Gemfile')) + + # Add a file with File.open, but give it a different name inside the transfer transfer.add_file( name: 'hello_world.rb', io: File.open('path/to/different_name.rb') ) + + # Using :name, :size and :io params. + # Specifying the size is not very useful in this case, but feel free to explicitly + # communicate the size of the coming io. + # + # The :name param is compulsory if it cannot be derived from the IO. transfer.add_file( name: 'README.txt', size: 31, @@ -88,8 +97,6 @@ client.create_transfer_and_upload_files(message: 'All the Things') do |transfer| ) end -transfer = client.transfer - # To get a link to your transfer, call `url` on your transfer object: transfer.url => "https://we.tl/t-1232346" diff --git a/lib/we_transfer.rb b/lib/we_transfer.rb index e316b7c..3254de7 100644 --- a/lib/we_transfer.rb +++ b/lib/we_transfer.rb @@ -5,8 +5,32 @@ require 'json' require 'ks' -%w[communication client transfer mini_io we_transfer_file remote_file version].each do |file| +%w[ + logging + communication + client + transfer + mini_io + we_transfer_file + remote_file version +].each do |file| require_relative "we_transfer/#{file}" end -module WeTransfer; end +module WeTransfer + NULL_LOGGER = Logger.new(nil) + + def self.logger + @logger || NULL_LOGGER + end + + # Set the logger to your preferred logger + # + # @params new_logger [Logger] the logger that WeTransfer SDK should use + # + # example: + # WeTransfer.logger = Rails.logger + def self.logger=(new_logger) + @logger = new_logger + end +end diff --git a/lib/we_transfer/client.rb b/lib/we_transfer/client.rb index 95b2a4f..e8c3bca 100644 --- a/lib/we_transfer/client.rb +++ b/lib/we_transfer/client.rb @@ -1,43 +1,30 @@ module WeTransfer class Client class Error < StandardError; end - include Communication - - NullLogger = Logger.new(nil) - - attr_reader :transfer # Initialize a WeTransfer::Client # # @param api_key [String] The API key you want to authenticate with - # @param logger [Logger] (NullLogger) your custom logger # # @return [WeTransfer::Client] - def initialize(api_key:, logger: NullLogger) - Communication.reset_authentication! - Communication.api_key = api_key - Communication.logger = logger + def initialize(api_key:) + @communicator = Communication.new(api_key) end def create_transfer(**args, &block) - transfer = WeTransfer::Transfer.new(args) + transfer = WeTransfer::Transfer.new(args.merge(communicator: @communicator)) transfer.persist(&block) - @transfer = transfer - - self end def create_transfer_and_upload_files(**args, &block) - create_transfer(args, &block) - @transfer.upload_files - @transfer.complete_files - @transfer.finalize - - self + transfer = create_transfer(args, &block) + transfer.upload_files + transfer.complete_files + transfer.finalize end def find_transfer(transfer_id) - @transfer = WeTransfer::Transfer.find(transfer_id) + @communicator.find_transfer(transfer_id) end end end diff --git a/lib/we_transfer/communication.rb b/lib/we_transfer/communication.rb index 47062d4..60717e9 100644 --- a/lib/we_transfer/communication.rb +++ b/lib/we_transfer/communication.rb @@ -1,167 +1,216 @@ module WeTransfer class CommunicationError < StandardError; end - module Communication + class Communication + include Logging extend Forwardable API_URL_BASE = "https://dev.wetransfer.com" + AUTHORIZE_URI = "/v2/authorize" + FINALIZE_URI = "/v2/transfers/%s/finalize" + TRANSFER_URI = "/v2/transfers/%s" + TRANSFERS_URI = "/v2/transfers" + UPLOAD_URL_URI = "/v2/transfers/%s/files/%s/upload-url/%s" + DEFAULT_HEADERS = { "User-Agent" => "WetransferRubySdk/#{WeTransfer::VERSION} Ruby #{RUBY_VERSION}", "Content-Type" => "application/json" }.freeze - class << self - attr_accessor :logger, :api_key, :bearer_token - - def reset_authentication! - @api_key = nil - @bearer_token = nil - @request_as = nil - end - - def find_transfer(transfer_id) - response = request_as.get("/v2/transfers/%s" % [transfer_id]) - ensure_ok_status!(response) - response_body = remote_transfer_params(response.body) - found_transfer = Transfer.new(message: response_body[:message]) - setup_transfer( - transfer: found_transfer, - data: response_body - ) - end - - def upload_url_for_chunk(transfer_id, file_id, chunk) - response = request_as.get("/v2/transfers/%s/files/%s/upload-url/%s" % [transfer_id, file_id, chunk]) - ensure_ok_status!(response) + attr_accessor :api_key - JSON.parse(response.body).fetch("url") - end - - def persist_transfer(transfer) - response = request_as.post( - "/v2/transfers", - transfer.as_request_params.to_json, - ) - ensure_ok_status!(response) + def initialize(api_key) + @api_key = api_key + end - handle_new_transfer_data( - transfer: transfer, - data: remote_transfer_params(response.body) - ) - end + # Instantiate a transfer from a transfer id, by talking to the WeTransfer Public API + # + # @param transfer_id [String] the id of the transfer you want to find + # + # @raise [WeTransfer::CommunicationError] if the transfer cannot be found + # + # @return [WeTransfer::Transfer] + def find_transfer(transfer_id) + response = request_as.get(TRANSFER_URI % transfer_id) + ensure_ok_status!(response) + response_body = remote_transfer_params(response.body) + found_transfer = Transfer.new(message: response_body[:message], communicator: self) + setup_transfer( + transfer: found_transfer, + data: response_body + ) + end - def finalize_transfer(transfer) - response = request_as.put("/v2/transfers/%s/finalize" % transfer.id) - ensure_ok_status!(response) - handle_new_transfer_data( - transfer: transfer, - data: remote_transfer_params(response.body) - ) - end + # GET an URL we can PUT a chunk of a file to. + # Note that this URL is valid for one hour, so if a lot of chunks have to be upload + # (or a slow network is expected), take care to not request your URLs too early + # + # @param transfer_id [String] the id of the transfer the file belongs to + # @param file_id [String] the id of the file the URL is requested for + # @param chunk [String, Number] the (1 based) chunk number of the file + # + # @raise [WeTransfer::CommunicationError] if the request to the WeTransfer Public API + # cannot be satisfied (e.g. transfer not found, file id unknown, chunk out of bound) + # + # @return [String] a signed URL, valid for an hour + # + def upload_url_for_chunk(transfer_id, file_id, chunk) + response = request_as.get(UPLOAD_URL_URI % [transfer_id, file_id, chunk]) + ensure_ok_status!(response) + + JSON.parse(response.body).fetch("url") + end - def remote_transfer_params(response_body) - JSON.parse(response_body, symbolize_names: true) - end + # Send a request to WeTransfer's Public API to create a transfer with + # a message and some files + # + # @param [Transfer] the transfer that should persist on WeTransfer + # + # @raise [WeTransfer::CommunicationError] if the request to the WeTransfer Public API + # cannot be satisfied (e.g. transfer has no message, files with duplicate names) + # + # @return [WeTransfer::Transfer] the transfer as persisted on WeTransfer. Persisting a transfer + # changes the state of the transfer, so it is the same object that was sent in as param, + # but some instance variables will be different. + # + def persist_transfer(transfer) + response = request_as.post( + TRANSFERS_URI, + transfer.as_persist_params.to_json, + ) + ensure_ok_status!(response) + + handle_new_transfer_data( + transfer: transfer, + data: remote_transfer_params(response.body) + ) + end - def upload_chunk(put_url, chunk_contents) - @chunk_uploader ||= Faraday.new { |c| minimal_faraday_config(c) } + # Send a request to WeTransfer's Public API to signal that all chunks of all files are done + # uploading, and that the transfer is should be processed for download. + # + # @param [Transfer] the transfer that should be finalized + # + # @raise [WeTransfer::CommunicationError] if the request to the WeTransfer Public API + # cannot be satisfied (e.g. not all chunks are uploaded, too much or too little data + # is uploaded) + # + # @return [WeTransfer::Transfer] the transfer. Finalizing changes the state of the transfer, + # so it is the same object that was sent in as param, but some instance variables will + # be different. + # + def finalize_transfer(transfer) + response = request_as.put(FINALIZE_URI % transfer.id) + ensure_ok_status!(response) + handle_new_transfer_data( + transfer: transfer, + data: remote_transfer_params(response.body) + ) + end - @chunk_uploader.put( - put_url, - chunk_contents.read, - 'Content-Type' => 'binary/octet-stream', - 'Content-Length' => chunk_contents.size.to_s - ) - end + def remote_transfer_params(response_body) + JSON.parse(response_body, symbolize_names: true) + end - def complete_file(transfer_id, file_id, chunks) - response = request_as.put( - "/v2/transfers/%s/files/%s/upload-complete" % [transfer_id, file_id], - { part_numbers: chunks }.to_json - ) + def upload_chunk(put_url, chunk_contents) + @chunk_uploader ||= Faraday.new { |c| minimal_faraday_config(c) } - ensure_ok_status!(response) - remote_transfer_params(response.body) - end + @chunk_uploader.put( + put_url, + chunk_contents.read, + 'Content-Type' => 'binary/octet-stream', + 'Content-Length' => chunk_contents.size.to_s + ) + end - private + def complete_file(transfer_id, file_id, chunks) + response = request_as.put( + "/v2/transfers/%s/files/%s/upload-complete" % [transfer_id, file_id], + { part_numbers: chunks }.to_json + ) - def request_as - @request_as ||= Faraday.new(API_URL_BASE) do |c| - minimal_faraday_config(c) - c.headers = auth_headers.merge DEFAULT_HEADERS - end - end + ensure_ok_status!(response) + remote_transfer_params(response.body) + end - def setup_transfer(transfer:, data:) - data[:files].each do |file_params| - transfer.add_file( - name: file_params[:name], - size: file_params[:size], - ) - end + private - handle_new_transfer_data(transfer: transfer, data: data) + def request_as + @request_as ||= Faraday.new(API_URL_BASE) do |c| + minimal_faraday_config(c) + c.headers = auth_headers.merge DEFAULT_HEADERS end + end - def handle_new_transfer_data(transfer:, data:) - %i[id state url].each do |i_var| - transfer.instance_variable_set "@#{i_var}", data[i_var] - end - - RemoteFile.upgrade( - transfer: transfer, - files_response: data[:files] + def setup_transfer(transfer:, data:) + data[:files].each do |file_params| + transfer.add_file( + name: file_params[:name], + size: file_params[:size], ) - transfer end - def auth_headers - authorize_if_no_bearer_token! + handle_new_transfer_data(transfer: transfer, data: data) + end - { - 'X-API-Key' => api_key, - 'Authorization' => "Bearer #{@bearer_token}" - } + def handle_new_transfer_data(transfer:, data:) + %i[id state url].each do |i_var| + transfer.instance_variable_set "@#{i_var}", data[i_var] end - def ensure_ok_status!(response) - case response.status - when 200..299 - true - when 400..499 - logger.error response - raise WeTransfer::CommunicationError, JSON.parse(response.body)["message"] - when 500..504 - logger.error response - raise WeTransfer::CommunicationError, "Response had a #{response.status} code, we could retry" - else - logger.error response - raise WeTransfer::CommunicationError, "Response had a #{response.status} code, no idea what to do with that" - end - end + RemoteFile.upgrade( + transfer: transfer, + files_response: data[:files] + ) + transfer + end - def authorize_if_no_bearer_token! - return @bearer_token if @bearer_token + def auth_headers + authorize_if_no_bearer_token! - response = Faraday.new(API_URL_BASE) do |c| - minimal_faraday_config(c) - c.headers = DEFAULT_HEADERS.merge('X-API-Key' => api_key) - end.post( - '/v2/authorize', - ) - ensure_ok_status!(response) - bearer_token = JSON.parse(response.body)['token'] - raise WeTransfer::CommunicationError, "The authorization call returned #{response.body} and no usable :token key could be found there" if bearer_token.nil? || bearer_token.empty? - @bearer_token = bearer_token - end + { + 'X-API-Key' => api_key, + 'Authorization' => "Bearer #{@bearer_token}" + } + end - def minimal_faraday_config(config) - config.response :logger, logger - config.adapter Faraday.default_adapter + def ensure_ok_status!(response) + case response.status + when 200..299 + true + when 400..499 + logger.error response + raise WeTransfer::CommunicationError, JSON.parse(response.body)["message"] + when 500..504 + logger.error response + raise WeTransfer::CommunicationError, "Response had a #{response.status} code, we could retry" + else + logger.error response + raise WeTransfer::CommunicationError, "Response had a #{response.status} code, no idea what to do with that" end end - def_delegator self, :minimal_faraday_config + def authorize_if_no_bearer_token! + return @bearer_token if @bearer_token + + response = Faraday.new(API_URL_BASE) do |c| + minimal_faraday_config(c) + c.headers = DEFAULT_HEADERS.merge('X-API-Key' => api_key) + end.post( + '/v2/authorize', + ) + ensure_ok_status!(response) + bearer_token = JSON.parse(response.body)['token'] + raise WeTransfer::CommunicationError, "The authorization call returned #{response.body} and no usable :token key could be found there" if bearer_token.nil? || bearer_token.empty? + @bearer_token = bearer_token + end + + def minimal_faraday_config(config) + config.response :logger, logger + config.adapter Faraday.default_adapter + end end + + # def_delegator self, :minimal_faraday_config + # end end diff --git a/lib/we_transfer/logging.rb b/lib/we_transfer/logging.rb new file mode 100644 index 0000000..9cb192a --- /dev/null +++ b/lib/we_transfer/logging.rb @@ -0,0 +1,7 @@ +module WeTransfer + module Logging + def logger + WeTransfer.logger + end + end +end diff --git a/lib/we_transfer/transfer.rb b/lib/we_transfer/transfer.rb index b7674c9..b3614eb 100644 --- a/lib/we_transfer/transfer.rb +++ b/lib/we_transfer/transfer.rb @@ -8,19 +8,15 @@ class FileMismatchError < StandardError; end attr_reader :files, :id, :state, :url, :message - class << self - extend Forwardable - def_delegator Communication, :find_transfer, :find - end - - def self.create(message:, &block) - transfer = new(message: message) + def self.create(message:, communicator:, &block) + transfer = new(message: message, communicator: communicator) transfer.persist(&block) end - def initialize(message:) + def initialize(message:, communicator:) @message = message + @communicator = communicator @files = [] @unique_file_names = Set.new end @@ -29,7 +25,7 @@ def persist yield(self) if block_given? raise NoFilesAddedError if @unique_file_names.empty? - Communication.persist_transfer(self) + @communicator.persist_transfer(self) end # Add one or more files to a transfer, so a transfer can be created over the @@ -54,13 +50,12 @@ def upload_file(name:, io: nil, file: nil) WeTransfer::RemoteFile::NoIoError, "IO for file with name '#{name}' cannot be uploaded." ) unless WeTransfer::MiniIO.mini_io_able?(put_io) - (1..file.multipart.chunks).each do |chunk| - put_url = upload_url_for_chunk(name: name, chunk: chunk) + put_url = upload_url_for_chunk(file_id: file.id, chunk: chunk) chunk_contents = StringIO.new(put_io.read(file.multipart.chunk_size)) chunk_contents.rewind - Communication.upload_chunk(put_url, chunk_contents) + @communicator.upload_chunk(put_url, chunk_contents) end end @@ -74,14 +69,18 @@ def upload_files end end - def upload_url_for_chunk(name:, chunk:) - file_id = find_file_by_name(name).id - Communication.upload_url_for_chunk(id, file_id, chunk) + def upload_url_for_chunk(file_id: nil, name: nil, chunk:) + raise ArgumentError, "missing keyword: either name or file_id is required" unless file_id || name + + file_id ||= find_file_by_name(name).id + @communicator.upload_url_for_chunk(id, file_id, chunk) end - def complete_file(name:, file: nil) + def complete_file(file: nil, name: file&.name) + raise ArgumentError, "missing keyword: either name or file is required" unless file || name + file ||= find_file_by_name(name) - Communication.complete_file(id, file.id, file.multipart.chunks) + @communicator.complete_file(id, file.id, file.multipart.chunks) end def complete_files @@ -94,13 +93,13 @@ def complete_files end def finalize - Communication.finalize_transfer(self) + @communicator.finalize_transfer(self) end - def as_request_params + def as_persist_params { message: @message, - files: @files.map(&:as_request_params), + files: @files.map(&:as_persist_params), } end diff --git a/lib/we_transfer/we_transfer_file.rb b/lib/we_transfer/we_transfer_file.rb index 516f147..86db57d 100644 --- a/lib/we_transfer/we_transfer_file.rb +++ b/lib/we_transfer/we_transfer_file.rb @@ -10,7 +10,7 @@ def initialize(name: nil, size: nil, io: nil) raise ArgumentError, "Need a file name and a size, or io should provide it" unless @name && @size end - def as_request_params + def as_persist_params { name: @name, size: @size, diff --git a/spec/features/client_creates_a_transfer_spec.rb b/spec/features/client_creates_a_transfer_spec.rb index 435f136..77b2758 100644 --- a/spec/features/client_creates_a_transfer_spec.rb +++ b/spec/features/client_creates_a_transfer_spec.rb @@ -10,10 +10,9 @@ let(:client) { WeTransfer::Client.new(api_key: ENV.fetch("WT_API_KEY")) } it "Convenience method create_transfer_and_upload_files does it all!" do - client.create_transfer_and_upload_files(message: "test transfer") do |transfer| + transfer = client.create_transfer_and_upload_files(message: "test transfer") do |transfer| transfer.add_file(name: "small_file_with_io", size: 10, io: StringIO.new("#" * 10)) end - transfer = client.transfer expect(transfer.files.map(&:name)) .to include("small_file_with_io") @@ -24,14 +23,12 @@ end it "Create a client, a transfer, it uploads files and finalizes the transfer" do - client.create_transfer(message: "test transfer") do |transfer| + transfer = client.create_transfer(message: "test transfer") do |transfer| transfer.add_file(name: "small_file", size: 80) transfer.add_file(name: "small_file_with_io", size: 10, io: StringIO.new("#" * 10)) transfer.add_file(name: "multi_chunk_big_file", io: File.open('spec/fixtures/Japan-01.jpg')) end - transfer = client.transfer - expect(transfer.url) .to be_nil diff --git a/spec/we_transfer/client_spec.rb b/spec/we_transfer/client_spec.rb new file mode 100644 index 0000000..2a7673e --- /dev/null +++ b/spec/we_transfer/client_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' + +describe WeTransfer::Client do + subject { described_class.new(params) } + let(:params) { { api_key: ENV.fetch('WT_API_KEY') } } + + it 'exposes VERSION' do + expect(WeTransfer::VERSION).to be_kind_of(String) + end + + describe "initializer" do + subject(:client) { described_class.new(api_key: 'fake key')} + + it "needs an :api_key kw param" do + expect { described_class.new } + .to raise_error(ArgumentError, %r|api_key|) + + expect { described_class.new(api_key: 'fake key') } + .to_not raise_error(ArgumentError, %r|api_key|) + end + + it "resets credentials (to prevent caching outdated credentials)" do + expect(WeTransfer::Communication) + .to receive(:reset_authentication!) + + subject + end + + it "sends the api_key to the Comm" do + expect(WeTransfer::Communication) + .to receive(:reset_authentication!) + + subject + end + + end + + describe "#create_transfer" do + let(:transfer) { instance_double(WeTransfer::Transfer) } + + it "instantiates a Transfer" do + allow(transfer) + .to receive(:persist) + + expect(WeTransfer::Transfer) + .to receive(:new) + .with(message: 'fake transfer') + .and_return(transfer) + + subject.create_transfer(message: 'fake transfer') + end + + it "stores the transfer in @transfer" do + allow(transfer) + .to receive(:persist) + + allow(WeTransfer::Transfer) + .to receive(:new) + .and_return(transfer) + subject.create_transfer(message: 'test transfer') + + expect(subject.instance_variable_get(:@transfer)).to eq transfer + end + + it "accepts a block, that is passed to the Transfer instance" do + allow(WeTransfer::Transfer) + .to receive(:new) + .with(message: 'test transfer') + .and_return(transfer) + + expect(transfer) + .to receive(:persist) { |&transfer| transfer.call(name: 'test file', size: 8) } + + expect { |probe| subject.create_transfer(message: 'test transfer', &probe) } + .to yield_with_args(name: 'test file', size: 8) + end + + it "returns self" do + allow(transfer) + .to receive(:persist) + + allow(WeTransfer::Transfer) + .to receive(:new) + .and_return(transfer) + + expect(subject.create_transfer(message: 'test transfer')).to eq subject + end + end + + describe "#find_transfer" do + it "delegates to Transfer.find" do + transfer_id = 'fake-transfer-id' + expect(WeTransfer::Transfer) + .to receive(:find) + .with(transfer_id) + + subject.find_transfer(transfer_id) + end + + it "stores the found transfer in @transfer" + end +end diff --git a/spec/we_transfer/communication_helper_spec.rb b/spec/we_transfer/communication_helper_spec.rb deleted file mode 100644 index 9ad9212..0000000 --- a/spec/we_transfer/communication_helper_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -require 'spec_helper' - -describe WeTransfer::Communication do - context ".ensure_ok_status!" do - subject { described_class } - - before { pending } - - before(:all) do - Response = Struct.new(:status, :body) - WeTransfer::Communication.logger = Logger.new(nil) - end - - context "on success" do - it "returns true if the status code is in the 2xx range" do - (200..299).each do |status_code| - response = Response.new(status_code) - expect(subject.ensure_ok_status!(response)).to be_truthy - end - end - end - - context "unsuccessful" do - it "raises with a message including the status code the server returned" do - response = Response.new("404") - expect { subject.ensure_ok_status!(response) } - .to raise_error(WeTransfer::CommunicationError, %r/Response had a 404 code/) - - response = Response.new("Meh") - expect { subject.ensure_ok_status!(response) } - .to raise_error(WeTransfer::CommunicationError, %r/Response had a Meh code/) - end - - it "if there is a server error, it raises with information that we can retry" do - (500..504).each do |status_code| - response = Response.new(status_code) - expect { subject.ensure_ok_status!(response) } - .to raise_error(WeTransfer::CommunicationError, /we could retry/) - end - end - - it "on client error, it raises with information that the server responded" do - (400..499).each do |status_code| - response = Response.new(status_code, { "message" => 'this is a test' }.to_json) - expect { subject.ensure_ok_status!(response) } - .to raise_error(WeTransfer::CommunicationError, 'this is a test') - end - end - - it "if the status code is unknown, it raises a generic error" do - response = Response.new("I'm no status code") - expect { subject.ensure_ok_status!(response) } - .to raise_error(WeTransfer::CommunicationError, /no idea what to do/) - end - end - end -end diff --git a/spec/we_transfer/communication_spec.rb b/spec/we_transfer/communication_spec.rb index f6bd3a9..ac3cd16 100644 --- a/spec/we_transfer/communication_spec.rb +++ b/spec/we_transfer/communication_spec.rb @@ -1,21 +1,23 @@ require 'spec_helper' describe WeTransfer::Communication do - before { described_class.logger = Logger.new(nil) } + subject(:communicator) { described_class.new('fake api key') } - # In this test we use the call to #find_transfer. This is NOT exemplary, it serves only - # the purpose of doing a request/response cycle to handle specific status codes (2xx, 4xx, 50x) - context ".ensure_ok_status!" do + let(:fake_transfer_id) { "fake-transfer-id" } + let(:fake_file_id) { "fake-file-id" } - let!(:authentication_stub) { - stub_request(:post, "#{WeTransfer::Communication::API_URL_BASE}/v2/authorize") - .to_return(status: 200, body: {token: "fake-test-token"}.to_json, headers: {}) - } + let!(:authentication_stub) { + stub_request(:post, described_class::API_URL_BASE + described_class::AUTHORIZE_URI) + .to_return(status: 200, body: { token: "fake-test-token" }.to_json, headers: {}) + } + # In this test we use the call to #find_transfer. This is NOT exemplary, it serves only + # the purpose of doing a request/response cycles to handle specific status codes (2xx, 4xx, 50x) + describe ".ensure_ok_status!" do context "on a successful request" do let!(:find_transfer_stub) do - stub_request(:get, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id"). - to_return( + stub_request(:get, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id") + .to_return( status: 200, body: { id: "24cd3f4ccf15232e5660052a3688c03f20190221200022", @@ -41,8 +43,9 @@ end it "does not raise a CommunicationError" do - expect { described_class.find_transfer('fake-transfer-id') } - .to_not raise_error(WeTransfer::CommunicationError) + subject.find_transfer('fake-transfer-id') + expect { subject.find_transfer('fake-transfer-id') } + .to_not raise_error end end @@ -60,12 +63,12 @@ end it "raises a CommunicationError" do - expect { described_class.find_transfer('fake-transfer-id') } - .to raise_error(WeTransfer::CommunicationError) + expect { subject.find_transfer('fake-transfer-id') } + .to raise_error(WeTransfer::CommunicationError) end it "showing the error from the API to the user" do - expect { described_class.find_transfer('fake-transfer-id') } + expect { subject.find_transfer('fake-transfer-id') } .to raise_error('fake message') end end @@ -81,12 +84,12 @@ end it "raises a CommunicationError" do - expect { described_class.find_transfer('fake-transfer-id') } + expect { subject.find_transfer('fake-transfer-id') } .to raise_error(WeTransfer::CommunicationError) end it "is telling we can try again" do - expect { described_class.find_transfer('fake-transfer-id') } + expect { subject.find_transfer('fake-transfer-id') } .to raise_error(%r|had a 501 code.*could retry|) end @@ -101,20 +104,289 @@ end it "raises a CommunicationError" do - expect { described_class.find_transfer('fake-transfer-id') } + expect { subject.find_transfer('fake-transfer-id') } .to raise_error(WeTransfer::CommunicationError) end it "includes the error code in the message" do - expect { described_class.find_transfer('fake-transfer-id') } + expect { subject.find_transfer('fake-transfer-id') } .to raise_error(%r|had a 302 code|) end it "informs the user we have no way how to continue" do - expect { described_class.find_transfer('fake-transfer-id') } + expect { subject.find_transfer('fake-transfer-id') } .to raise_error(%r|no idea what to do with that|) end end end end + + describe "#find_transfer" do + let(:fake_transfer_id) { "fake-transfer-id" } + + let(:transfer_body) do + { + id: fake_transfer_id, + state: "uploading", + message: "test transfer", + url: nil, + files: files_stub, + expires_at: "2019-02-28T20:00:22Z" + } + end + + let(:files_stub) do + [ + { + id: "fake_file_id", + name: "test file", + size: 8, + multipart: { + part_numbers: 1, + chunk_size: 8 + }, + type: "file", + } + ] + end + + let!(:find_transfer_stub) do + stub_request( + :get, + described_class::API_URL_BASE + (described_class::TRANSFER_URI % fake_transfer_id) + ) + .to_return( + status: 200, + body: transfer_body.to_json, + headers: {}, + ) + end + + it "needs a transfer_id param" do + expect { communicator.find_transfer } + .to raise_error(ArgumentError) + end + + it "sends a GET to TRANSFER_URI" do + allow(WeTransfer::Transfer) + .to receive(:new) + .and_return(instance_double(WeTransfer::Transfer)) + + allow(communicator) + .to receive(:setup_transfer) + + communicator.find_transfer('fake-transfer-id') + + expect(find_transfer_stub) + .to have_been_requested + end + + it "instantiates a WeTransfer::Transfer" do + expect(WeTransfer::Transfer) + .to receive(:new) + .with(message: "test transfer", communicator: communicator) + + allow(communicator) + .to receive(:setup_transfer) + + communicator.find_transfer('fake-transfer-id') + end + + it "instantiates the Transfer with all expected attributes and values" do + expected = { + files: [ + { + id: "fake_file_id", + multipart: { + chunk_size: 8, + chunks: 1 + }, + name: "test file", + size: 8 + } + ], + id: "fake-transfer-id", + message: "test transfer", + state: "uploading", + url: nil + } + + expect(communicator.find_transfer('fake-transfer-id').to_h) + .to eq expected + end + end + + describe "#upload_url_for_chunk" do + let(:chunk) { 1 } + let(:fake_upload_url) { "https://fake.upload.url/123" } + + let!(:upload_url_stub) do + stub_request( + :get, + described_class::API_URL_BASE + ( + described_class::UPLOAD_URL_URI % [fake_transfer_id, fake_file_id, chunk] + ) + ) + .to_return( + status: 200, + body: { success: true, url: fake_upload_url }.to_json, + headers: {}, + ) + end + + it "should be invoked with arguments for transfer_id, file_id and chunk" do + expect { communicator.upload_url_for_chunk(fake_transfer_id, fake_file_id, chunk) } + .not_to raise_error + end + + it "returns the url from the response of the WeTransfer Public API" do + expect(communicator.upload_url_for_chunk(fake_transfer_id, fake_file_id, chunk)) + .to eq fake_upload_url + end + end + + describe "#persist_transfer" do + let!(:persist_transfer_stub) do + stub_request(:post, described_class::API_URL_BASE + described_class::TRANSFERS_URI) + .to_return( + status: 200, + body: { + success: true, + id: fake_transfer_id, + state: persisted_transfer_state, + message: "test transfer", + url: nil, + files: [ + { + id: fake_file_id, + name: "test file", + size: 8, + multipart: { + part_numbers: 1, + chunk_size: 8 + }, + type: "file" + } + ], + expires_at: "2019-03-13T16:14:02Z" + }.to_json, + headers: {}, + ) + end + + let(:transfer) do + WeTransfer::Transfer.new( + message: 'fake transfer', + communicator: communicator + ).add_file(name: 'test file', size: 8) + end + + let(:persisted_transfer_state) { "fake transfer state" } + + it "is invoked with a Transfer instance as argument" do + # allow(transfer) + # .to receive(:as_persist_params) + # .and_return(id: fake_transfer_id) + + # allow(transfer) + # .to receive(:find_file_by_name) + # .and_return(id: fake_file_id) + + expect { communicator.persist_transfer(transfer) } + .not_to raise_error + end + + it "invokes :as_persist_params on the transfer" do + # allow(transfer) + # .to receive(:find_file_by_name) + # .and_return(id: fake_file_id) + + expect(transfer) + .to receive(:as_persist_params) + .and_return(id: fake_transfer_id) + + communicator.persist_transfer(transfer) + end + + it "assigns the transfer an @id, @state and @url" do + expect(transfer) + .to receive(:instance_variable_set) + .with("@id", fake_transfer_id) + + expect(transfer) + .to receive(:instance_variable_set) + .with("@state", persisted_transfer_state) + + expect(transfer) + .to receive(:instance_variable_set) + .with("@url", nil) + + communicator.persist_transfer(transfer) + end + end + + describe "#finalize_transfer" do + let(:transfer_url) { "https://ready.for/download" } + let(:finalized_transfer_state) { "fake finalized state" } + + let!(:finalize_transfer_stub) do + stub_request(:put, described_class::API_URL_BASE + (described_class::FINALIZE_URI % fake_transfer_id)) + .to_return( + status: 200, + body: { + success: true, + id: fake_transfer_id, + state: finalized_transfer_state, + message: "test transfer", + url: transfer_url, + files: [ + { + id: fake_file_id, + name: "test file", + size: 8, + multipart: { + part_numbers: 1, + chunk_size: 8 + }, + type: "file" + } + ], + expires_at: "2019-03-13T16:14:02Z" + }.to_json, + headers: {}, + ) + end + + let(:transfer) do + WeTransfer::Transfer.new( + message: 'fake transfer', + communicator: communicator + ).add_file(name: 'test file', size: 8) + end + + before do + allow(transfer) + .to receive(:id) + .and_return(fake_transfer_id) + end + + it "is invoked with a Transfer instance" do + expect { communicator.finalize_transfer(transfer) } + .not_to raise_error + end + + it "assigns the transfer an @url" do + allow(transfer) + .to receive(:instance_variable_set) + + expect(transfer) + .to receive(:instance_variable_set) + .with("@url", transfer_url) + + communicator.finalize_transfer(transfer) + end + end + describe "#remote_transfer_params" + describe "#upload_chunk" + describe "#complete_file" end diff --git a/spec/we_transfer/transfer_spec.rb b/spec/we_transfer/transfer_spec.rb index d8cf428..4eda853 100644 --- a/spec/we_transfer/transfer_spec.rb +++ b/spec/we_transfer/transfer_spec.rb @@ -1,88 +1,48 @@ require "spec_helper" describe WeTransfer::Transfer do - let!(:authentication_stub) { - stub_request(:post, "#{WeTransfer::Communication::API_URL_BASE}/v2/authorize") - .to_return(status: 200, body: {token: "fake-test-token"}.to_json, headers: {}) - } - - let!(:create_transfer_stub) do - stub_request(:post, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers"). - to_return( - status: 200, - body: { - success: true, - id: "24cd3f4ccf15232e5660052a3688c03f20190221200022", - state: "uploading", - message: "test transfer", - url: nil, - files: files_stub, - expires_at: "2019-02-28T20:00:22Z" - }.to_json, - headers: {}, - ) - end - - let!(:find_transfer_stub) do - stub_request(:get, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id"). - to_return( - status: 200, - body: { - id: "24cd3f4ccf15232e5660052a3688c03f20190221200022", - state: "uploading", - message: "test transfer", - url: nil, - files: files_stub, - expires_at: "2019-02-28T20:00:22Z" - }.to_json, - headers: {}, - ) - end - - let(:files_stub) { - [ - { - id: "fake_file_id", - name: "test file", - size: 8, - multipart: { - part_numbers: 1, - chunk_size: 8 - }, - type: "file", - } - ] - } + let(:communicator) { instance_double(WeTransfer::Communication) } describe ".create" do it "instantiates a transfer" do expect(described_class) .to receive(:new) - .with(message: "through .create") + .with(message: "test transfer", communicator: communicator) .and_call_original begin - described_class.create(message: "through .create") + described_class.create( + message: "test transfer", + communicator: communicator + ) rescue WeTransfer::Transfer::NoFilesAddedError - # TODO: a rescue that would break an initializer is kinda moot in an instantiation - # test ¯\_(ツ)_/¯ + # We're rescuing here: This tests just that :create calls the initializer. end end it "calls #persist on the instantiated transfer" do add_file_lambda = ->(transfer) { transfer.add_file(name: "test file", size: 8) } + allow(communicator) + .to receive(:persist_transfer) + expect_any_instance_of(described_class) .to receive(:persist) do |*_args, &block| expect(add_file_lambda).to be(block) end .and_call_original - described_class.create(message: "through .create", &add_file_lambda) + described_class.create( + message: "fake transfer", + communicator: communicator, + &add_file_lambda + ) end end describe ".find" do + before { skip("This interface is removed") } + it "GETs the transfer by its id" do described_class.find("fake-transfer-id") @@ -102,20 +62,34 @@ end describe "initialize" do - it "passes with a message" do - expect { described_class.new(message: "test transfer") } + it "needs a :message and a :communicator kw argument" do + expect { + described_class.new( + message: "test transfer", + communicator: communicator + ) + } .to_not raise_error end - it "fails without a message" do - expect { described_class.new } + it "fails without a :message" do + expect { described_class.new(communicator: communicator) } .to raise_error ArgumentError, %r|message| end + + it "fails without a :communicator" do + expect { described_class.new(message: "test transfer") } + .to raise_error ArgumentError, %r|communicator| + end end describe "#files" do it "is a getter for @files" do - transfer = described_class.new(message: "test transfer") + transfer = described_class.new( + message: "test transfer", + communicator: communicator + ) + transfer.instance_variable_set :@files, "this is a fake" expect(transfer.files).to eq "this is a fake" @@ -123,6 +97,13 @@ end describe "#persist" do + subject(:transfer) { + described_class.new( + message: "test transfer", + communicator: communicator + ) + } + context "adding multiple files, using a block" do let(:files_stub) { [ @@ -149,55 +130,47 @@ } it "works on a transfer without any files" do - transfer = described_class.new(message: "test transfer") + # transfer = described_class.new(message: "test transfer") + + expect(communicator) + .to receive(:persist_transfer) transfer.persist do |transfer| transfer.add_file(name: "file1", size: 8) transfer.add_file(name: "file2", size: 8) end - - expect(create_transfer_stub) - .to have_been_requested end - it "can be used on a transfer that already has some files" do - transfer = described_class.new(message: "test transfer") + it "can be used on a transfer that already has some files" do + # transfer = described_class.new(message: "test transfer") transfer.add_file(name: "file1", size: 8) - transfer.persist { |transfer| transfer.add_file(name: "file2", size: 8) } + expect(communicator) + .to receive(:persist_transfer) - expect(create_transfer_stub) - .to have_been_requested + transfer.persist { |transfer| transfer.add_file(name: "file2", size: 8) } end end context "not adding files, using a block" do it "can create a remote transfer if the transfer already had one" do - transfer = described_class.new(message: "test transfer") + # transfer = described_class.new(message: "test transfer") transfer.add_file(name: "test file", size: 8) - transfer.persist + expect(communicator) + .to receive(:persist_transfer) - expect(create_transfer_stub) - .to have_been_requested + transfer.persist end end - - context "with files" do - it "can create a remote transfer if the transfer " - end - context "with files" do - it "creates a remote transfer" - it "exposes new methods on the WeTransferFile instances" - end end describe "#add_file" do subject(:transfer) do - described_class.new(message: "test transfer") do |transfer| - # this block is used to add files using - # transfer.add_file(name:, size:, io:) - end + described_class.new( + message: "test transfer", + communicator: communicator + ) end let(:file_params) { { name: "test file", size: :bar, io: :baz } } @@ -272,229 +245,355 @@ end describe "#upload_file" do + subject(:transfer) do + described_class.new( + message: "test transfer", + communicator: communicator + ) + end + it "should be called with :name" do - transfer = described_class.new(message: "test transfer") transfer.add_file(name: "test file", size: 8) expect { transfer.upload_file } .to raise_error ArgumentError, %r|name| end - context "without io keyword param" do - it "works if the WeTransferFile instance has an io" do - transfer = described_class.new(message: "test transfer") - transfer.add_file(name: "test file", size: 8, io: StringIO.new("12345678")) - transfer.persist + context "without io keyword param," do + context "if the WeTransferFile instance has an io" do + it "calls upload_chunk on the communicator" do + transfer.add_file(name: "test file", size: 8, io: StringIO.new("12345678")) + + # transfer.persist # does a network call + # fake-persist the transfer so we can spec the complete_file run + multipart = instance_double( + WeTransfer::RemoteFile::Multipart, + chunks: 1, + chunk_size: 20 + ) + transfer.instance_variable_set :@id, 'fake-transfer-id' + transfer.files.each do |f| + f.instance_variable_set :@id, 'fake-file-id' + f.instance_variable_set :@multipart, multipart + end + + allow(transfer) + .to receive(:upload_url_for_chunk) + .with(file_id: "fake-file-id", chunk: kind_of(Numeric)) + .and_return("https://fake.upload.url/123") + + expect(communicator) + .to receive(:upload_chunk) + .with("https://fake.upload.url/123", kind_of(StringIO)) + + transfer.upload_file(name: "test file") + end - allow(transfer) - .to receive(:upload_url_for_chunk) - .with(name: "test file", chunk: kind_of(Numeric)) - .and_return("https://signed.url/123") + it "invokes :upload_url_for_chunk to obtain a PUT url" do + transfer.add_file(name: "test file", size: 8, io: StringIO.new("12345678")) + + # transfer.persist # does a network call + # fake-persist the transfer so we can spec the complete_file run + multipart = instance_double( + WeTransfer::RemoteFile::Multipart, + chunks: 1, + chunk_size: 20 + ) + transfer.instance_variable_set :@id, 'fake-transfer-id' + transfer.files.each do |f| + f.instance_variable_set :@id, 'fake-file-id' + f.instance_variable_set :@multipart, multipart + end - stub_request(:put, "https://signed.url/123") - .to_return(status: 200, body: "", headers: {}) + allow(communicator) + .to receive(:upload_chunk) - expect { transfer.upload_file(name: "test file") } - .to_not raise_error + expect(transfer) + .to receive(:upload_url_for_chunk) + .with(file_id: "fake-file-id", chunk: 1) + + transfer.upload_file(name: "test file") + end end it "breaks if the WeTransferFile instance does not have an io" do - transfer = described_class.new(message: "test transfer") transfer.add_file(name: "test file", size: 8) expect { transfer.upload_file(name: "test file") } .to raise_error WeTransfer::RemoteFile::NoIoError, %r|'test file' cannot be uploaded| end - - it "invokes :upload_url_for_chunk to obtain a PUT url" do - transfer = described_class.new(message: "test transfer") - transfer.add_file(name: "test file", size: 8, io: StringIO.new("12345678")) - transfer.persist - - stub_request(:put, "https://signed.url/123") - .to_return(status: 200, body: "", headers: {}) - - expect(transfer) - .to receive(:upload_url_for_chunk) - .with(name: "test file", chunk: kind_of(Numeric)) - .and_return("https://signed.url/123") - - transfer.upload_file(name: "test file") - end end context "uploads each chunk" do it "upload is triggered once if the io smaller than the server's chunk size" do - transfer = described_class.new(message: "test transfer") - file_name = "test file" contents = "12345678" - upload_request_stub = stub_request(:put, "https://signed.url/123") - .with(body: contents) - transfer.add_file(name: file_name, io: StringIO.new(contents)) - transfer.persist + + # transfer.persist # does a network call + # fake-persist the transfer so we can spec the complete_file run + multipart = instance_double( + WeTransfer::RemoteFile::Multipart, + chunks: 1, + chunk_size: 20 + ) + transfer.instance_variable_set :@id, 'fake-transfer-id' + transfer.files.each do |f| + f.instance_variable_set :@id, 'fake-file-id' + f.instance_variable_set :@multipart, multipart + end + + allow(communicator) + .to receive(:upload_chunk) expect(transfer) .to receive(:upload_url_for_chunk) - .with(name: file_name, chunk: 1) + .with(file_id: 'fake-file-id', chunk: 1) .and_return("https://signed.url/123") .once transfer.upload_file(name: file_name) - - expect(upload_request_stub).to have_been_requested end - it "upload is triggered twice if the io is 1.5 times the server's chunk size" do - remove_request_stub(create_transfer_stub) - + it "upload is triggered twice if the io is has 2 chunks" do file_name = "test file" - file_size = 7_500_000 + file_size = 1_000 contents = "-" * file_size - stub_request(:post, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers") - .to_return( - status: 200, - body: { - success: true, - id: "04a1828e9a193adacb3ea110cdcf773320190226161412", - state: "uploading", - message: "test transfer", - url: nil, - files: [ - { - id: "3a2b2c5e01657dbe09e104aa5fdd01f020190226161412", - name: file_name, - size: file_size, - multipart: { - part_numbers: 2, - chunk_size: 5242880 - }, - type: "file" - } - ], - expires_at: "2019-03-05T1614:12Z" - }.to_json, - headers: {} - ) + transfer.add_file(name: file_name, size: file_size, io: StringIO.new(contents)) + # transfer.persist # does a network call + # fake-persist the transfer so we can spec the complete_file run + multipart = instance_double( + WeTransfer::RemoteFile::Multipart, + chunks: 2, + chunk_size: 750 # not the right number, but we allow the server to set it + ) + transfer.instance_variable_set(:@id, 'fake-transfer-id') - transfer = described_class.new(message: "test transfer") + transfer.files.each do |f| + f.instance_variable_set :@id, 'fake-file-id' + f.instance_variable_set :@multipart, multipart + end expect(transfer) .to receive(:upload_url_for_chunk) - .with(name: file_name, chunk: kind_of(Numeric)) - .and_return("https://signed.url/big-chunk-1", "https://signed.url/big-chunk-2") + .with(file_id: 'fake-file-id', chunk: kind_of(Numeric)) + .and_return("https://fake.upload.url/big-chunk-1", "https://fake.upload.url/big-chunk-2") - put_1 = stub_request(:put, "https://signed.url/big-chunk-1") - put_2 = stub_request(:put, "https://signed.url/big-chunk-2") - - transfer.add_file(name: file_name, size: file_size, io: StringIO.new(contents)) - transfer.persist + expect(communicator) + .to receive(:upload_chunk) + .with(%r|https://fake.upload.url/big-chunk-\d|, kind_of(StringIO)) + .exactly(2).times transfer.upload_file(name: file_name) - expect(put_1).to have_been_requested - expect(put_2).to have_been_requested end end end - describe "#upload_url_for_chunk" do - it "does something" + describe "#upload_files" do + subject(:transfer) do + described_class.new( + message: "test transfer", + communicator: communicator + ) + end + + let(:file_factory) { Struct.new(:name) } + + it "invokes :upload_file for each file in the files collection" do + transfer.instance_variable_set(:@files, 2.times.map { |n| file_factory.new("file-#{n}") }) + + expect(transfer) + .to receive(:upload_file) + .with(hash_including(:file, :name)) + .twice + + transfer.upload_files + end end - describe "#complete_file" do - it "works" do - transfer = described_class.new(message: "test transfer") - transfer.add_file(name: "test file", size: 8) - transfer.persist + describe "#upload_url_for_chunk" do + subject(:transfer) do + described_class.new( + message: "test transfer", + communicator: communicator + ) + end + it "must be invoked with a :chunk kw param" do + expect { transfer.upload_url_for_chunk } + .to raise_error(ArgumentError, %r|chunk|) + end + + it "must be invoked with either a :file_id or :name kw param" do + expect { transfer.upload_url_for_chunk(chunk: :foo) } + .to raise_error(ArgumentError, %r|name.*file_id|) + + allow(communicator) + .to receive(:upload_url_for_chunk) + + transfer.add_file(name: 'bar', size: 8) + transfer.files.first.instance_variable_set :@id, 'baz' + + expect { transfer.upload_url_for_chunk(chunk: :foo, name: 'bar') } + .not_to raise_error + + expect { transfer.upload_url_for_chunk(chunk: :foo, file_id: 'baz') } + .not_to raise_error + end + + it "invokes :upload_url_for_chunk on the communicator" do allow(transfer) .to receive(:id) - .and_return("transfer_id") + .and_return 'fake-transfer-id' - allow(transfer.files.first) - .to receive(:id) - .and_return("file_id") + file = Struct.new(:id).new('fake-file-id') + chunk = 3 - upload_complete_stub = stub_request(:put, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/transfer_id/files/file_id/upload-complete"). - with( - body: { part_numbers: 1 }.to_json, - ) - .to_return( - status: 200, - body: { - success: true, - id: "26a8bb18b75d8c67c744cdf3655c3fdd20190227112811", - retries: 0, - name: "test_file_1", - size: 10, - chunk_size: 5242880 - }.to_json, - headers: {} - ) + expect(communicator) + .to receive(:upload_url_for_chunk) + .with(transfer.id, file.id, chunk) - transfer.complete_file(name: "test file") + transfer.upload_url_for_chunk(file_id: file.id, chunk: chunk) + end + end - expect(upload_complete_stub).to have_been_requested + describe "#complete_file" do + subject(:transfer) do + described_class.new( + message: "test transfer", + communicator: communicator + ) + end + + let(:file) { + instance_double( + WeTransfer::WeTransferFile, + id: 8, + name: 'meh', + multipart: multipart + ) + } + + let(:multipart) { + instance_double( + WeTransfer::RemoteFile::Multipart, + chunks: 1, + chunk_size: 20 + ) + } + + it "can be invoked with a :name kw param" do + transfer.add_file(name: 'meh', size: 8) + + # fake-persist the transfer so we can spec the complete_file run + transfer.instance_variable_set :@id, 'fake-transfer-id' + transfer.files.each do |f| + f.instance_variable_set :@id, 'fake-file-id' + f.instance_variable_set :@multipart, multipart + end + + expect(communicator) + .to receive(:complete_file) + + transfer.complete_file(name: 'meh') + end + + it "can be invoked with a :file kw param" do + allow(communicator) + .to receive(:complete_file) + + transfer.complete_file(file: file) + end + + it "if invoked with both a :name or a :file param, the :file is used" do + allow(communicator) + .to receive(:complete_file) + + expect(transfer) + .to_not receive(:find_file_by_name) + + transfer.complete_file(name: 'foo', file: file) + end + + it "needs to be invoked with either a :name or a :file kw param" do + expect { transfer.complete_file } + .to raise_error(ArgumentError, %r|name.*file|) end end - describe "#finalize" do + describe "#complete_files" do subject(:transfer) do - WeTransfer::Transfer - .new(message: "test transfer") - .add_file(name: "test file", size: 30) + described_class.new( + message: "test transfer", + communicator: communicator + ) + end + + let(:file_factory) { Struct.new(:name) } + + it "invokes :complete_file for each file in the files collection" do + transfer.instance_variable_set(:@files, 2.times.map { |n| file_factory.new("file-#{n}") }) + + expect(transfer) + .to receive(:complete_file) + .with(hash_including(:file, :name)) + .twice + + transfer.complete_files end + end - let!(:finalize_transfer_stub) do - stub_request(:put, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id/finalize"). - to_return( - status: 200, - body: { - success: true, - id: "fake-transfer-id", - state: "processing", - message: "test transfer", - url: "https://we.tl/t-c5mHAyq1iO", - files: [ - { - id: "b40157b36830c0ca37059af5b054a45b20190225084239", - name: "test file", - size: 30, - multipart: { - part_numbers: 1, - chunk_size: 30 - }, - type: "file" - } - ], - expires_at: "2019-03-04T08:42:39Z" - }.to_json, - headers: {}, + describe "#finalize" do + subject(:transfer) do + WeTransfer::Transfer + .new( + message: "test transfer", + communicator: communicator, ) + .add_file(name: "test file", size: 30) end before { allow(transfer).to receive(:id).and_return("fake-transfer-id") } - it "PUTs to the finalize endpoint" do + it "invokes :finalize_transfer on the communicator" do + expect(communicator) + .to receive(:finalize_transfer) + .with(transfer) + transfer.finalize + end + end - expect(finalize_transfer_stub).to have_been_requested + describe "#as_persist_params" do + subject(:transfer) do + described_class.new( + message: "test transfer", + communicator: communicator + ) end - it "gets a new status" do - expect(transfer.state).to be_nil - transfer.finalize - expect(transfer.state).to eq "processing" + it "returns a hash with the right values for :message and :files" do + expected = { message: "test transfer", files: [] } + expect(transfer.as_persist_params) + .to eq expected end - it "url?" + it "invokes :as_persist_params on each member of files" do + transfer.add_file(name: "test file 1", size: 8) + transfer.add_file(name: "test file 2", size: 8) + + transfer.files.each do |file| + expect(file) + .to receive(:as_persist_params) + end + transfer.as_persist_params + end end describe "#to_h" do - let(:transfer) { described_class.new(message: 'test transfer') } + let(:transfer) { described_class.new(message: 'test transfer', communicator: nil) } let(:file_stub_1) { [instance_double(WeTransfer::WeTransferFile, name: 'foo', size: 8)] } it "has keys and values for id, state, url, message and files" do @@ -549,7 +648,7 @@ describe "#to_json" do it "converts the results of #to_h" do - transfer = described_class.new(message: 'test transfer') + transfer = described_class.new(message: 'test transfer', communicator: nil) transfer_hash = { "foo" => "bar" } diff --git a/spec/we_transfer/we_transfer_file_spec.rb b/spec/we_transfer/we_transfer_file_spec.rb index 45bed57..408ffdf 100644 --- a/spec/we_transfer/we_transfer_file_spec.rb +++ b/spec/we_transfer/we_transfer_file_spec.rb @@ -51,11 +51,11 @@ end end - describe "#as_request_params" do + describe "#as_persist_params" do subject(:file) { described_class.new(name: 'test file', size: 8) } it "has key/values for name and size only" do - expect(file.as_request_params.to_json) + expect(file.as_persist_params.to_json) .to eq %|{"name":"test file","size":8}| end end diff --git a/spec/we_transfer_client_spec.rb b/spec/we_transfer_client_spec.rb index 3bdf4ca..e69de29 100644 --- a/spec/we_transfer_client_spec.rb +++ b/spec/we_transfer_client_spec.rb @@ -1,79 +0,0 @@ -require 'spec_helper' - -describe WeTransfer::Client do - subject { described_class.new(params) } - let(:params) { { api_key: ENV.fetch('WT_API_KEY') } } - - it 'exposes VERSION' do - expect(WeTransfer::VERSION).to be_kind_of(String) - end - - describe "#create_transfer" do - let(:transfer) { instance_double(WeTransfer::Transfer) } - - it "raises an ArgumentError without the :message keyword param" do - expect { subject.create_transfer }.to raise_error(ArgumentError, %r/message/) - end - - it "instantiates a Transfer" do - allow(transfer) - .to receive(:persist) - - expect(WeTransfer::Transfer) - .to receive(:new) - .with(message: 'fake transfer') - .and_return(transfer) - - subject.create_transfer(message: 'fake transfer') - end - - it "stores the transfer in @transfer" do - allow(transfer) - .to receive(:persist) - - allow(WeTransfer::Transfer) - .to receive(:new) - .and_return(transfer) - subject.create_transfer(message: 'test transfer') - - expect(subject.instance_variable_get(:@transfer)).to eq transfer - end - - it "accepts a block, that is passed to the Transfer instance" do - allow(WeTransfer::Transfer) - .to receive(:new) - .with(message: 'test transfer') - .and_return(transfer) - - expect(transfer) - .to receive(:persist) { |&transfer| transfer.call(name: 'test file', size: 8) } - - expect { |probe| subject.create_transfer(message: 'test transfer', &probe) } - .to yield_with_args(name: 'test file', size: 8) - end - - it "returns self" do - allow(transfer) - .to receive(:persist) - - allow(WeTransfer::Transfer) - .to receive(:new) - .and_return(transfer) - - expect(subject.create_transfer(message: 'test transfer')).to eq subject - end - end - - describe "#find_transfer" do - it "delegates to Transfer.find" do - transfer_id = 'fake-transfer-id' - expect(WeTransfer::Transfer) - .to receive(:find) - .with(transfer_id) - - subject.find_transfer(transfer_id) - end - - it "stores the found transfer in @transfer" - end -end From d578838f4887cbc4954687a07e668729d9d82006 Mon Sep 17 00:00:00 2001 From: Arno Date: Thu, 7 Mar 2019 17:07:22 +0100 Subject: [PATCH 10/18] Rename to Communicator, small refactorings for chainging methods on a transfer, and unfuddle the :ensure_ok_status! --- lib/we_transfer.rb | 2 +- lib/we_transfer/client.rb | 14 ++-- .../{communication.rb => communicator.rb} | 77 +++++++++---------- lib/we_transfer/mini_io.rb | 3 +- lib/we_transfer/transfer.rb | 2 + .../client_creates_a_transfer_spec.rb | 17 ++-- spec/spec_helper.rb | 15 +--- spec/we_transfer/client_spec.rb | 75 ++++++++---------- ...unication_spec.rb => communicator_spec.rb} | 10 +-- spec/we_transfer/transfer_spec.rb | 2 +- 10 files changed, 94 insertions(+), 123 deletions(-) rename lib/we_transfer/{communication.rb => communicator.rb} (80%) rename spec/we_transfer/{communication_spec.rb => communicator_spec.rb} (95%) diff --git a/lib/we_transfer.rb b/lib/we_transfer.rb index 3254de7..0ea847e 100644 --- a/lib/we_transfer.rb +++ b/lib/we_transfer.rb @@ -7,7 +7,7 @@ %w[ logging - communication + communicator client transfer mini_io diff --git a/lib/we_transfer/client.rb b/lib/we_transfer/client.rb index e8c3bca..1703be5 100644 --- a/lib/we_transfer/client.rb +++ b/lib/we_transfer/client.rb @@ -1,6 +1,7 @@ module WeTransfer class Client class Error < StandardError; end + extend Forwardable # Initialize a WeTransfer::Client # @@ -8,7 +9,7 @@ class Error < StandardError; end # # @return [WeTransfer::Client] def initialize(api_key:) - @communicator = Communication.new(api_key) + @communicator = Communicator.new(api_key) end def create_transfer(**args, &block) @@ -18,13 +19,12 @@ def create_transfer(**args, &block) def create_transfer_and_upload_files(**args, &block) transfer = create_transfer(args, &block) - transfer.upload_files - transfer.complete_files - transfer.finalize + transfer + .upload_files + .complete_files + .finalize end - def find_transfer(transfer_id) - @communicator.find_transfer(transfer_id) - end + def_delegator :@communicator, :find_transfer end end diff --git a/lib/we_transfer/communication.rb b/lib/we_transfer/communicator.rb similarity index 80% rename from lib/we_transfer/communication.rb rename to lib/we_transfer/communicator.rb index 60717e9..14d433a 100644 --- a/lib/we_transfer/communication.rb +++ b/lib/we_transfer/communicator.rb @@ -1,7 +1,7 @@ module WeTransfer class CommunicationError < StandardError; end - class Communication + class Communicator include Logging extend Forwardable @@ -17,8 +17,6 @@ class Communication "Content-Type" => "application/json" }.freeze - attr_accessor :api_key - def initialize(api_key) @api_key = api_key end @@ -31,14 +29,15 @@ def initialize(api_key) # # @return [WeTransfer::Transfer] def find_transfer(transfer_id) - response = request_as.get(TRANSFER_URI % transfer_id) - ensure_ok_status!(response) + response = ensure_ok_status!(request_as.get(TRANSFER_URI % transfer_id)) + response_body = remote_transfer_params(response.body) found_transfer = Transfer.new(message: response_body[:message], communicator: self) setup_transfer( transfer: found_transfer, data: response_body ) + found_transfer end # GET an URL we can PUT a chunk of a file to. @@ -55,8 +54,7 @@ def find_transfer(transfer_id) # @return [String] a signed URL, valid for an hour # def upload_url_for_chunk(transfer_id, file_id, chunk) - response = request_as.get(UPLOAD_URL_URI % [transfer_id, file_id, chunk]) - ensure_ok_status!(response) + response = ensure_ok_status!(request_as.get(UPLOAD_URL_URI % [transfer_id, file_id, chunk])) JSON.parse(response.body).fetch("url") end @@ -74,13 +72,14 @@ def upload_url_for_chunk(transfer_id, file_id, chunk) # but some instance variables will be different. # def persist_transfer(transfer) - response = request_as.post( - TRANSFERS_URI, - transfer.as_persist_params.to_json, + response = ensure_ok_status!( + request_as.post( + TRANSFERS_URI, + transfer.as_persist_params.to_json, + ) ) - ensure_ok_status!(response) - handle_new_transfer_data( + handle_remote_transfer_data( transfer: transfer, data: remote_transfer_params(response.body) ) @@ -100,18 +99,13 @@ def persist_transfer(transfer) # be different. # def finalize_transfer(transfer) - response = request_as.put(FINALIZE_URI % transfer.id) - ensure_ok_status!(response) - handle_new_transfer_data( + response = ensure_ok_status!(request_as.put(FINALIZE_URI % transfer.id)) + handle_remote_transfer_data( transfer: transfer, data: remote_transfer_params(response.body) ) end - def remote_transfer_params(response_body) - JSON.parse(response_body, symbolize_names: true) - end - def upload_chunk(put_url, chunk_contents) @chunk_uploader ||= Faraday.new { |c| minimal_faraday_config(c) } @@ -124,17 +118,22 @@ def upload_chunk(put_url, chunk_contents) end def complete_file(transfer_id, file_id, chunks) - response = request_as.put( - "/v2/transfers/%s/files/%s/upload-complete" % [transfer_id, file_id], - { part_numbers: chunks }.to_json + response = ensure_ok_status!( + request_as.put( + "/v2/transfers/%s/files/%s/upload-complete" % [transfer_id, file_id], + { part_numbers: chunks }.to_json + ) ) - ensure_ok_status!(response) remote_transfer_params(response.body) end private + def remote_transfer_params(response_body) + JSON.parse(response_body, symbolize_names: true) + end + def request_as @request_as ||= Faraday.new(API_URL_BASE) do |c| minimal_faraday_config(c) @@ -150,10 +149,10 @@ def setup_transfer(transfer:, data:) ) end - handle_new_transfer_data(transfer: transfer, data: data) + handle_remote_transfer_data(transfer: transfer, data: data) end - def handle_new_transfer_data(transfer:, data:) + def handle_remote_transfer_data(transfer:, data:) %i[id state url].each do |i_var| transfer.instance_variable_set "@#{i_var}", data[i_var] end @@ -162,6 +161,7 @@ def handle_new_transfer_data(transfer:, data:) transfer: transfer, files_response: data[:files] ) + transfer end @@ -169,37 +169,33 @@ def auth_headers authorize_if_no_bearer_token! { - 'X-API-Key' => api_key, + 'X-API-Key' => @api_key, 'Authorization' => "Bearer #{@bearer_token}" } end def ensure_ok_status!(response) + return response if (200..299).include?(response.status) + + logger.error(response) case response.status - when 200..299 - true when 400..499 - logger.error response raise WeTransfer::CommunicationError, JSON.parse(response.body)["message"] when 500..504 - logger.error response raise WeTransfer::CommunicationError, "Response had a #{response.status} code, we could retry" - else - logger.error response - raise WeTransfer::CommunicationError, "Response had a #{response.status} code, no idea what to do with that" end + raise WeTransfer::CommunicationError, "Response had a #{response.status} code, no idea what to do with that" end def authorize_if_no_bearer_token! return @bearer_token if @bearer_token - response = Faraday.new(API_URL_BASE) do |c| - minimal_faraday_config(c) - c.headers = DEFAULT_HEADERS.merge('X-API-Key' => api_key) - end.post( - '/v2/authorize', + response = ensure_ok_status!( + Faraday.new(API_URL_BASE) do |c| + minimal_faraday_config(c) + c.headers = DEFAULT_HEADERS.merge('X-API-Key' => @api_key) + end.post(AUTHORIZE_URI) ) - ensure_ok_status!(response) bearer_token = JSON.parse(response.body)['token'] raise WeTransfer::CommunicationError, "The authorization call returned #{response.body} and no usable :token key could be found there" if bearer_token.nil? || bearer_token.empty? @bearer_token = bearer_token @@ -210,7 +206,4 @@ def minimal_faraday_config(config) config.adapter Faraday.default_adapter end end - - # def_delegator self, :minimal_faraday_config - # end end diff --git a/lib/we_transfer/mini_io.rb b/lib/we_transfer/mini_io.rb index dfdb1c6..e1abe00 100644 --- a/lib/we_transfer/mini_io.rb +++ b/lib/we_transfer/mini_io.rb @@ -40,13 +40,12 @@ def initialize(io) end # The name of the io, guessed using File.basename. If this raises a TypeError - # we swallow the error, since this is used only as the default name for a + # we swallow the error, since this is used only as fallback for naming a # WeTransferFile # def name File.basename(@io) rescue TypeError - # yeah, what? end def_delegators :@io, :read, :rewind, :seek, :size diff --git a/lib/we_transfer/transfer.rb b/lib/we_transfer/transfer.rb index b3614eb..c4ac73d 100644 --- a/lib/we_transfer/transfer.rb +++ b/lib/we_transfer/transfer.rb @@ -67,6 +67,7 @@ def upload_files file: file ) end + self end def upload_url_for_chunk(file_id: nil, name: nil, chunk:) @@ -90,6 +91,7 @@ def complete_files file: file ) end + self end def finalize diff --git a/spec/features/client_creates_a_transfer_spec.rb b/spec/features/client_creates_a_transfer_spec.rb index 77b2758..dc3e687 100644 --- a/spec/features/client_creates_a_transfer_spec.rb +++ b/spec/features/client_creates_a_transfer_spec.rb @@ -12,10 +12,13 @@ it "Convenience method create_transfer_and_upload_files does it all!" do transfer = client.create_transfer_and_upload_files(message: "test transfer") do |transfer| transfer.add_file(name: "small_file_with_io", size: 10, io: StringIO.new("#" * 10)) + transfer.add_file(io: File.open('Gemfile')) end + # the transfer is (soon) ready to be downloaded. Time to inspect it: + expect(transfer.files.map(&:name)) - .to include("small_file_with_io") + .to match_array(%w[small_file_with_io Gemfile]) expect(transfer.state) .to eq "processing" expect(transfer.url) @@ -26,15 +29,15 @@ transfer = client.create_transfer(message: "test transfer") do |transfer| transfer.add_file(name: "small_file", size: 80) transfer.add_file(name: "small_file_with_io", size: 10, io: StringIO.new("#" * 10)) - transfer.add_file(name: "multi_chunk_big_file", io: File.open('spec/fixtures/Japan-01.jpg')) + transfer.add_file(name: "multi_chunk_big_image.jpg", io: File.open('spec/fixtures/Japan-01.jpg')) end - expect(transfer.url) - .to be_nil + expect(transfer.url.nil?) + .to eq true expect(transfer.id.nil?).to eq false expect(transfer.state).to eq "uploading" - expect(transfer.files.none? { |f| f.id.nil? }).to eq true + expect(transfer.files.any? { |f| f.id.nil? }).to eq false transfer.upload_file(name: "small_file", io: StringIO.new("#" * 80)) transfer.complete_file(name: "small_file") @@ -42,8 +45,8 @@ transfer.upload_file(name: "small_file_with_io") transfer.complete_file(name: "small_file_with_io") - transfer.upload_file(name: "multi_chunk_big_file") - transfer.complete_file(name: "multi_chunk_big_file") + transfer.upload_file(name: "multi_chunk_big_image.jpg") + transfer.complete_file(name: "multi_chunk_big_image.jpg") transfer.finalize diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8a297ae..e92f5b9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -15,20 +15,7 @@ require 'webmock/rspec' Dotenv.load -module SpecHelpers - def fixtures_dir - __dir__ + '/fixtures/' - end - - def test_logger - Logger.new($stderr).tap { |log| log.level = Logger::WARN } - end -end - RSpec.configure do |config| - config.include SpecHelpers - config.extend SpecHelpers - config.expect_with :rspec do |c| c.syntax = :expect end @@ -38,3 +25,5 @@ def test_logger config.default_formatter = 'doc' config.order = :random end + +RSpec::Mocks.configuration.verify_partial_doubles = true diff --git a/spec/we_transfer/client_spec.rb b/spec/we_transfer/client_spec.rb index 2a7673e..16bed24 100644 --- a/spec/we_transfer/client_spec.rb +++ b/spec/we_transfer/client_spec.rb @@ -1,71 +1,68 @@ require 'spec_helper' describe WeTransfer::Client do - subject { described_class.new(params) } - let(:params) { { api_key: ENV.fetch('WT_API_KEY') } } + subject(:client) { described_class.new(api_key: fake_key) } + let(:fake_key) { "fake key" } + let(:communicator) { instance_double(WeTransfer::Communicator) } it 'exposes VERSION' do expect(WeTransfer::VERSION).to be_kind_of(String) end - describe "initializer" do - subject(:client) { described_class.new(api_key: 'fake key')} + before do + allow(WeTransfer::Communicator) + .to receive(:new) + .and_return(communicator) + end + describe "initializer" do it "needs an :api_key kw param" do expect { described_class.new } .to raise_error(ArgumentError, %r|api_key|) expect { described_class.new(api_key: 'fake key') } - .to_not raise_error(ArgumentError, %r|api_key|) + .to_not raise_error end - it "resets credentials (to prevent caching outdated credentials)" do - expect(WeTransfer::Communication) - .to receive(:reset_authentication!) + it "initializes a WeTransfer::Communicator" do + expect(WeTransfer::Communicator) + .to receive(:new) + .with(fake_key) subject end - it "sends the api_key to the Comm" do - expect(WeTransfer::Communication) - .to receive(:reset_authentication!) + it "stores the Communicator instance in @communicator" do + allow(WeTransfer::Communicator) + .to receive(:new) + .and_return(communicator) - subject + expect(subject.instance_variable_get(:@communicator)) + .to eq communicator end - end describe "#create_transfer" do let(:transfer) { instance_double(WeTransfer::Transfer) } - it "instantiates a Transfer" do + it "instantiates a Transfer with all needed arguments" do allow(transfer) .to receive(:persist) expect(WeTransfer::Transfer) .to receive(:new) - .with(message: 'fake transfer') + .with( + message: 'fake transfer', + communicator: communicator + ) .and_return(transfer) subject.create_transfer(message: 'fake transfer') end - it "stores the transfer in @transfer" do - allow(transfer) - .to receive(:persist) - - allow(WeTransfer::Transfer) - .to receive(:new) - .and_return(transfer) - subject.create_transfer(message: 'test transfer') - - expect(subject.instance_variable_get(:@transfer)).to eq transfer - end - - it "accepts a block, that is passed to the Transfer instance" do + it "accepts a block, that is passed to the persist method of the Transfer instance" do allow(WeTransfer::Transfer) .to receive(:new) - .with(message: 'test transfer') .and_return(transfer) expect(transfer) @@ -74,29 +71,17 @@ expect { |probe| subject.create_transfer(message: 'test transfer', &probe) } .to yield_with_args(name: 'test file', size: 8) end - - it "returns self" do - allow(transfer) - .to receive(:persist) - - allow(WeTransfer::Transfer) - .to receive(:new) - .and_return(transfer) - - expect(subject.create_transfer(message: 'test transfer')).to eq subject - end end describe "#find_transfer" do - it "delegates to Transfer.find" do + it "is delegated to communicator" do transfer_id = 'fake-transfer-id' - expect(WeTransfer::Transfer) - .to receive(:find) + + expect(communicator) + .to receive(:find_transfer) .with(transfer_id) subject.find_transfer(transfer_id) end - - it "stores the found transfer in @transfer" end end diff --git a/spec/we_transfer/communication_spec.rb b/spec/we_transfer/communicator_spec.rb similarity index 95% rename from spec/we_transfer/communication_spec.rb rename to spec/we_transfer/communicator_spec.rb index ac3cd16..ad8b8f5 100644 --- a/spec/we_transfer/communication_spec.rb +++ b/spec/we_transfer/communicator_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe WeTransfer::Communication do +describe WeTransfer::Communicator do subject(:communicator) { described_class.new('fake api key') } let(:fake_transfer_id) { "fake-transfer-id" } @@ -16,7 +16,7 @@ describe ".ensure_ok_status!" do context "on a successful request" do let!(:find_transfer_stub) do - stub_request(:get, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id") + stub_request(:get, "#{WeTransfer::Communicator::API_URL_BASE}/v2/transfers/fake-transfer-id") .to_return( status: 200, body: { @@ -51,7 +51,7 @@ context "on a client error" do let!(:find_transfer_stub) do - stub_request(:get, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id"). + stub_request(:get, "#{WeTransfer::Communicator::API_URL_BASE}/v2/transfers/fake-transfer-id"). to_return( status: 405, body: '{ @@ -75,7 +75,7 @@ context "on a server error" do let!(:find_transfer_stub) do - stub_request(:get, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id"). + stub_request(:get, "#{WeTransfer::Communicator::API_URL_BASE}/v2/transfers/fake-transfer-id"). to_return( status: 501, body: '', @@ -95,7 +95,7 @@ context "everything else" do let(:find_transfer_stub) do - stub_request(:get, "#{WeTransfer::Communication::API_URL_BASE}/v2/transfers/fake-transfer-id"). + stub_request(:get, "#{WeTransfer::Communicator::API_URL_BASE}/v2/transfers/fake-transfer-id"). to_return( status: 302, body: '', diff --git a/spec/we_transfer/transfer_spec.rb b/spec/we_transfer/transfer_spec.rb index 4eda853..44bce34 100644 --- a/spec/we_transfer/transfer_spec.rb +++ b/spec/we_transfer/transfer_spec.rb @@ -1,7 +1,7 @@ require "spec_helper" describe WeTransfer::Transfer do - let(:communicator) { instance_double(WeTransfer::Communication) } + let(:communicator) { instance_double(WeTransfer::Communicator) } describe ".create" do it "instantiates a transfer" do From af4eaaebe6b84b9b624df0cdb584c0759d90a653 Mon Sep 17 00:00:00 2001 From: Arno Date: Sat, 9 Mar 2019 20:19:51 +0100 Subject: [PATCH 11/18] Bump version to 0.10.x Document that 0.10.x only offers transfer support Document high level usage better. --- README.md | 63 +++++++++++++++++++++++++++++++++++--- lib/we_transfer/version.rb | 2 +- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a3a19b5..49e5d94 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ For your API key and additional info please visit our [developer portal](https:/ Add this line to your application's Gemfile: ```ruby +gem 'wetransfer', version: '0.10.0.beta1' + +# If you need Board support, as found in WeTransfer's Collect app, use version 0.9.x) gem 'wetransfer', version: '0.9.0.beta3' ``` @@ -82,7 +85,7 @@ transfer = client.create_transfer_and_upload_files(message: 'All the Things') do # Add a file with File.open, but give it a different name inside the transfer transfer.add_file( name: 'hello_world.rb', - io: File.open('path/to/different_name.rb') + io: File.open('path/to/file/with/different_name.rb') ) # Using :name, :size and :io params. @@ -98,18 +101,69 @@ transfer = client.create_transfer_and_upload_files(message: 'All the Things') do end # To get a link to your transfer, call `url` on your transfer object: -transfer.url => "https://we.tl/t-1232346" +transfer.url # => "https://we.tl/t-1232346" # Or inspect the whole transfer: -puts transfer.to_json +transfer.to_h # => +# { +# :id => "0d5ce492c0cd935b5376c7858b0ff5ae20190307162739", +# :state => "processing", +# :url => "https://we.tl/t-CVINGH30C4", +# :message => "test transfer", +# :files => [ +# { +# :name => "README.txt", +# :size => 31, +# :id => "0e04833491a31776770ac4dcf83d1f4a20190307162739", +# :multipart => { +# :chunks => 1, +# :chunk_size => 31 +# } +# }, { +# :name => "hello_world.rb", +# :size => 166, +# :id => "22423dd4b44300641a4659203ba5d1bb20190307162739", +# :multipart => { +# :chunks => 1, +# :chunk_size => 166 +# } +# } +# ] +# } ``` The upload will be performed at the end of the block. Depending on your file sizes and network connection speed, this might take some time. What are you waiting for? Open that link in your browser! Chop chop. +If you want to have more control over which files uploads when, it is also possible. + +```ruby +# Create a transfer that consists of 1 file. +transfer = client.create_transfer(message: "test transfer") do |transfer| + # When creating a transfer, at least the name and the size of the file(s) + # must be known. + transfer.add_file(name: "small_file", size: 80) +end + +# Upload the file. The Ruby SDK will upload the file in chunks. +transfer.upload_file(name: "small_file", io: StringIO.new("#" * 80)) + +# Mark the file as completely uploaded. All the chunks of the file will be joined +# together to recreate the file +transfer.complete_file(name: "small_file") + +# Mark the transfer as completely done, so your customers can start downloading it +transfer.finalize + +# Inspect your transfer. Use transfer.to_h or transfer.to_json, depending on your scenario +transfer.to_h +``` + ## Boards +**NOTE**: **Boards are disabled from version 0.10.x** of the Ruby SDK. The latest releases that include **board support is found in version 0.9.x** + A board is a collection of files and links, but it is open for modifications. Like your portfolio: While working, you can make adjustments to it. A board is a fantastic place for showcasing your work in progress. Boards need a WeTransfer Client to be present, just like transfers. @@ -127,7 +181,8 @@ After you create your client, you can ```ruby board = client.create_board(name: 'Meow', description: 'On Cats') do |items| - items.add_file(name: 'big file.jpg', io: File.open('/path/to/huge_file.jpg', 'rb')items.add_file_at(path: '/path/to/another/file.txt') + items.add_file(name: 'big file.jpg', io: File.open('/path/to/huge_file.jpg') + items.add_file_at(path: '/path/to/another/file.txt') items.add_web_url(url: 'http://wepresent.wetransfer.com', title: 'Time well spent') end diff --git a/lib/we_transfer/version.rb b/lib/we_transfer/version.rb index 3bccd95..3cbb068 100644 --- a/lib/we_transfer/version.rb +++ b/lib/we_transfer/version.rb @@ -1,3 +1,3 @@ module WeTransfer - VERSION = '0.9.0.beta3' + VERSION = '0.10.0.beta1' end From 8d0f98c429f805d98f603a7acfc2653d45855d16 Mon Sep 17 00:00:00 2001 From: Arno Date: Sat, 9 Mar 2019 20:29:10 +0100 Subject: [PATCH 12/18] freeze those strings --- lib/we_transfer/client.rb | 2 ++ lib/we_transfer/communicator.rb | 2 ++ lib/we_transfer/logging.rb | 2 ++ lib/we_transfer/mini_io.rb | 2 ++ lib/we_transfer/remote_file.rb | 2 ++ lib/we_transfer/transfer.rb | 2 ++ lib/we_transfer/version.rb | 2 ++ lib/we_transfer/we_transfer_file.rb | 2 ++ 8 files changed, 16 insertions(+) diff --git a/lib/we_transfer/client.rb b/lib/we_transfer/client.rb index 1703be5..06bf0cc 100644 --- a/lib/we_transfer/client.rb +++ b/lib/we_transfer/client.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module WeTransfer class Client class Error < StandardError; end diff --git a/lib/we_transfer/communicator.rb b/lib/we_transfer/communicator.rb index 14d433a..f2d4bf9 100644 --- a/lib/we_transfer/communicator.rb +++ b/lib/we_transfer/communicator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module WeTransfer class CommunicationError < StandardError; end diff --git a/lib/we_transfer/logging.rb b/lib/we_transfer/logging.rb index 9cb192a..8b9b9ec 100644 --- a/lib/we_transfer/logging.rb +++ b/lib/we_transfer/logging.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module WeTransfer module Logging def logger diff --git a/lib/we_transfer/mini_io.rb b/lib/we_transfer/mini_io.rb index e1abe00..6c86614 100644 --- a/lib/we_transfer/mini_io.rb +++ b/lib/we_transfer/mini_io.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module WeTransfer # Wrapper around an IO object, delegating only methods we need for creating # and sending a file in chunks. diff --git a/lib/we_transfer/remote_file.rb b/lib/we_transfer/remote_file.rb index dc241a9..48566f8 100644 --- a/lib/we_transfer/remote_file.rb +++ b/lib/we_transfer/remote_file.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module WeTransfer module RemoteFile class FileMismatchError < StandardError; end diff --git a/lib/we_transfer/transfer.rb b/lib/we_transfer/transfer.rb index c4ac73d..1b6536c 100644 --- a/lib/we_transfer/transfer.rb +++ b/lib/we_transfer/transfer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module WeTransfer class Transfer class DuplicateFileNameError < ArgumentError; end diff --git a/lib/we_transfer/version.rb b/lib/we_transfer/version.rb index 3cbb068..3394081 100644 --- a/lib/we_transfer/version.rb +++ b/lib/we_transfer/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module WeTransfer VERSION = '0.10.0.beta1' end diff --git a/lib/we_transfer/we_transfer_file.rb b/lib/we_transfer/we_transfer_file.rb index 86db57d..5036aa6 100644 --- a/lib/we_transfer/we_transfer_file.rb +++ b/lib/we_transfer/we_transfer_file.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module WeTransfer class WeTransferFile attr_reader :name, :id, :io, :multipart, :size From 30f68f9c44b123efe274754e98302de92f943296 Mon Sep 17 00:00:00 2001 From: Arno Date: Mon, 11 Mar 2019 14:46:09 +0100 Subject: [PATCH 13/18] Adds ability to find files by their id as well --- lib/we_transfer/transfer.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/we_transfer/transfer.rb b/lib/we_transfer/transfer.rb index 1b6536c..ba7e430 100644 --- a/lib/we_transfer/transfer.rb +++ b/lib/we_transfer/transfer.rb @@ -61,7 +61,6 @@ def upload_file(name:, io: nil, file: nil) end end - # def upload_files files.each do |file| upload_file( @@ -79,10 +78,10 @@ def upload_url_for_chunk(file_id: nil, name: nil, chunk:) @communicator.upload_url_for_chunk(id, file_id, chunk) end - def complete_file(file: nil, name: file&.name) - raise ArgumentError, "missing keyword: either name or file is required" unless file || name + def complete_file(file: nil, name: file&.name, file_id: file&.id) + raise ArgumentError, "missing keyword: either name or file is required" unless file || name || file_id - file ||= find_file_by_name(name) + file ||= find_file_by_name(name || file_id) @communicator.complete_file(id, file.id, file.multipart.chunks) end @@ -120,13 +119,14 @@ def to_h prepared end - def find_file_by_name(name) - @found_files ||= Hash.new do |h, name| - h[name] = files.find { |file| file.name == name } + def find_file_by_name_or_id(name_or_id) + @found_files ||= Hash.new do |h, name_or_id| + h[name_or_id] = files.find { |file| [file.name, file.id].include? name_or_id } end - raise FileMismatchError unless @found_files[name] - @found_files[name] + raise FileMismatchError unless @found_files[name_or_id] + @found_files[name_or_id] end + alias_method :find_file_by_name, :find_file_by_name_or_id end end From ac9a7e06abcac2ca163f5b62fb1a297874e683c6 Mon Sep 17 00:00:00 2001 From: Arno Date: Tue, 12 Mar 2019 13:25:13 +0100 Subject: [PATCH 14/18] Find file simplified and documented --- lib/we_transfer/remote_file.rb | 2 +- lib/we_transfer/transfer.rb | 17 ++++++++++++----- spec/we_transfer/transfer_spec.rb | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/lib/we_transfer/remote_file.rb b/lib/we_transfer/remote_file.rb index 48566f8..82b0ab4 100644 --- a/lib/we_transfer/remote_file.rb +++ b/lib/we_transfer/remote_file.rb @@ -9,7 +9,7 @@ class Multipart < ::Ks.strict(:chunks, :chunk_size); end def self.upgrade(files_response:, transfer:) files_response.each do |file_response| - local_file = transfer.find_file_by_name(file_response[:name]) + local_file = transfer.find_file(file_response[:name]) local_file.instance_variable_set( :@id, diff --git a/lib/we_transfer/transfer.rb b/lib/we_transfer/transfer.rb index ba7e430..c1abde3 100644 --- a/lib/we_transfer/transfer.rb +++ b/lib/we_transfer/transfer.rb @@ -45,7 +45,7 @@ def add_file(**args) end def upload_file(name:, io: nil, file: nil) - file ||= find_file_by_name(name) + file ||= find_file(name) put_io = io || file.io raise( @@ -74,14 +74,14 @@ def upload_files def upload_url_for_chunk(file_id: nil, name: nil, chunk:) raise ArgumentError, "missing keyword: either name or file_id is required" unless file_id || name - file_id ||= find_file_by_name(name).id + file_id ||= find_file(name).id @communicator.upload_url_for_chunk(id, file_id, chunk) end def complete_file(file: nil, name: file&.name, file_id: file&.id) raise ArgumentError, "missing keyword: either name or file is required" unless file || name || file_id - file ||= find_file_by_name(name || file_id) + file ||= find_file(name || file_id) @communicator.complete_file(id, file.id, file.multipart.chunks) end @@ -119,7 +119,15 @@ def to_h prepared end - def find_file_by_name_or_id(name_or_id) + # Find a file inside this transfer + # + # @param [String] name_or_id, The name (as set by you) or the id (as returned) + # by WeTransfer Public API of the file. + # @raise [FileMismatchError] if a file with that name or id cannot be found for + # this transfer + # @return [WeTransferFile] The file you requested + # + def find_file(name_or_id) @found_files ||= Hash.new do |h, name_or_id| h[name_or_id] = files.find { |file| [file.name, file.id].include? name_or_id } end @@ -127,6 +135,5 @@ def find_file_by_name_or_id(name_or_id) raise FileMismatchError unless @found_files[name_or_id] @found_files[name_or_id] end - alias_method :find_file_by_name, :find_file_by_name_or_id end end diff --git a/spec/we_transfer/transfer_spec.rb b/spec/we_transfer/transfer_spec.rb index 44bce34..27c52bb 100644 --- a/spec/we_transfer/transfer_spec.rb +++ b/spec/we_transfer/transfer_spec.rb @@ -512,7 +512,7 @@ .to receive(:complete_file) expect(transfer) - .to_not receive(:find_file_by_name) + .to_not receive(:find_file) transfer.complete_file(name: 'foo', file: file) end From 22c3bd8e854c834d548793fa9b0ef851eeca88d2 Mon Sep 17 00:00:00 2001 From: Arno Date: Tue, 12 Mar 2019 13:25:43 +0100 Subject: [PATCH 15/18] Adds more or better documentation --- lib/we_transfer/communicator.rb | 21 ++++++++--------- lib/we_transfer/transfer.rb | 13 +++++++---- lib/we_transfer/we_transfer_file.rb | 35 +++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/lib/we_transfer/communicator.rb b/lib/we_transfer/communicator.rb index f2d4bf9..a1a5161 100644 --- a/lib/we_transfer/communicator.rb +++ b/lib/we_transfer/communicator.rb @@ -23,13 +23,15 @@ def initialize(api_key) @api_key = api_key end - # Instantiate a transfer from a transfer id, by talking to the WeTransfer Public API + # Send a request to WeTransfer's Public API, resulting in the instantiation of a transfer # - # @param transfer_id [String] the id of the transfer you want to find + # @param transfer_id [String] The id of the transfer, as generated by + # the WeTransfer Public API, that you want to find. # - # @raise [WeTransfer::CommunicationError] if the transfer cannot be found + # @raise [WeTransfer::CommunicationError] If the transfer cannot be found + # + # @return [WeTransfer::Transfer] the transfer you requested # - # @return [WeTransfer::Transfer] def find_transfer(transfer_id) response = ensure_ok_status!(request_as.get(TRANSFER_URI % transfer_id)) @@ -87,18 +89,17 @@ def persist_transfer(transfer) ) end - # Send a request to WeTransfer's Public API to signal that all chunks of all files are done - # uploading, and that the transfer is should be processed for download. + # Send a request to WeTransfer's Public API to signal that the transfer should + # be locked and processed so it can be downloaded. # - # @param [Transfer] the transfer that should be finalized + # @param [Transfer] transfer The transfer that should be finalized # # @raise [WeTransfer::CommunicationError] if the request to the WeTransfer Public API # cannot be satisfied (e.g. not all chunks are uploaded, too much or too little data # is uploaded) # - # @return [WeTransfer::Transfer] the transfer. Finalizing changes the state of the transfer, - # so it is the same object that was sent in as param, but some instance variables will - # be different. + # @return [WeTransfer::Transfer] The transfer. This is the same object that was send in through + # the param, but some local state will be updated according to the result of the call # def finalize_transfer(transfer) response = ensure_ok_status!(request_as.put(FINALIZE_URI % transfer.id)) diff --git a/lib/we_transfer/transfer.rb b/lib/we_transfer/transfer.rb index c1abde3..3d2704a 100644 --- a/lib/we_transfer/transfer.rb +++ b/lib/we_transfer/transfer.rb @@ -30,12 +30,17 @@ def persist @communicator.persist_transfer(self) end - # Add one or more files to a transfer, so a transfer can be created over the - # WeTransfer public API + # Add a file to a transfer. # - # @params name [String] (nil) the name of the file + # @param args [Hash] (See WeTransferFile#initialize) + # # @option args :name [String] The name of the file, as you want it to show up + # # inside the transfer + # # @option args :size + # # @param name [String] (nil) the name of the file # - # @returns self [WeTransfer::Client] + # @raise [DuplicateFileNameError] If multiple files share the same name + # + # @return self [WeTransfer::Transfer] def add_file(**args) file = WeTransferFile.new(args) raise DuplicateFileNameError unless @unique_file_names.add?(file.name.downcase) diff --git a/lib/we_transfer/we_transfer_file.rb b/lib/we_transfer/we_transfer_file.rb index 5036aa6..27ba0c2 100644 --- a/lib/we_transfer/we_transfer_file.rb +++ b/lib/we_transfer/we_transfer_file.rb @@ -4,6 +4,41 @@ module WeTransfer class WeTransferFile attr_reader :name, :id, :io, :multipart, :size + # Construct a WeTransferFile + # + # The initializer will try its best to figure out the name and size if it + # isn't provided, but the io can provide it. + # + # @param :io [optional, anything] The io of the file. This will be wrapped + # in a MiniIo, or if absent in a NullMiniIo + # @param :name [optional, String] The name you want to give your file. This + # does not have to match the original file name. + # @param :size [optional, Numeric] The size of the file. Has to be exact to + # the byte. If omitted, the io will be used to figure out the size + # + # @raise [ArgumentError] If :name and :size arguments are empty and cannot + # be derived from the io. + # + # @example With only an io. This will figure out the name and size of the file using MiniIo. + # WeTransfer::WeTransferFile.new(io: File.open('Gemfile')) # => + # #>, + # @name="Gemfile", + # @size=166 + # > + # + # @example With only a name and size. + # WeTransfer::WeTransferFile.new(name: 'README.md', size: 1337) # => + # #, + # @name="README.md", + # @size=1337 + # > + # + # @see MiniIo.name + # @see MiniIo.size + # @see NullMiniIo + # def initialize(name: nil, size: nil, io: nil) @io = MiniIO.new(io) @name = name || @io.name From 789e3b305d0fbdbcad60ffa324552c3d5c6498f4 Mon Sep 17 00:00:00 2001 From: Arno Date: Fri, 22 Mar 2019 16:18:53 +0100 Subject: [PATCH 16/18] More Yardoc --- .ruby-version | 2 +- lib/we_transfer.rb | 6 +++--- lib/we_transfer/transfer.rb | 33 +++++++++++++++++++++++++++------ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.ruby-version b/.ruby-version index 6a6a3d8..097a15a 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.1 +2.6.2 diff --git a/lib/we_transfer.rb b/lib/we_transfer.rb index 0ea847e..c929eee 100644 --- a/lib/we_transfer.rb +++ b/lib/we_transfer.rb @@ -26,10 +26,10 @@ def self.logger # Set the logger to your preferred logger # - # @params new_logger [Logger] the logger that WeTransfer SDK should use + # @param new_logger [Logger] the logger that WeTransfer SDK should use # - # example: - # WeTransfer.logger = Rails.logger + # @example Send all logs to Rails logger + # WeTransfer.logger = Rails.logger def self.logger=(new_logger) @logger = new_logger end diff --git a/lib/we_transfer/transfer.rb b/lib/we_transfer/transfer.rb index 3d2704a..769c467 100644 --- a/lib/we_transfer/transfer.rb +++ b/lib/we_transfer/transfer.rb @@ -33,14 +33,15 @@ def persist # Add a file to a transfer. # # @param args [Hash] (See WeTransferFile#initialize) - # # @option args :name [String] The name of the file, as you want it to show up - # # inside the transfer - # # @option args :size - # # @param name [String] (nil) the name of the file # - # @raise [DuplicateFileNameError] If multiple files share the same name + # @raise [DuplicateFileNameError] Files should have a unique name - + # case insensitive - within a transfer. If that is not true, it + # will result in this error + # + # @return [WeTransfer::Transfer] self + # + # @see WeTransferFile#initialize # - # @return self [WeTransfer::Transfer] def add_file(**args) file = WeTransferFile.new(args) raise DuplicateFileNameError unless @unique_file_names.add?(file.name.downcase) @@ -49,6 +50,22 @@ def add_file(**args) self end + # Upload the file. Convenience method for uploading the parts of the file in + # chunks. This will upload all chunks in order, single threaded. + # + # @param :name [String] The name used to add the file to the transfer + # @param :io [optional, String] The contents to be uploaded. If the file was + # added (See Transfer#add_file) including an io, this can be omitted. + # If the file was added including an io, *and* it is included in this + # call, the io from this invocation will be uploaded. + # @param :file [optional, WeTransferFile] The file instance that will be + # uploaded. + # + # @raise [WeTransfer::RemoteFile::NoIoError] Will be raised if the io does + # not meet the minimal requirements (see MiniIo.mini_io_able?) + # @example + # + # @see MiniIo.mini_io_able? def upload_file(name:, io: nil, file: nil) file ||= find_file(name) put_io = io || file.io @@ -66,6 +83,10 @@ def upload_file(name:, io: nil, file: nil) end end + # Trigger the upload for all files. Since this method does not accept any io, + # the files should be added (see #add_file) with a proper io object. + # + # @return [WeTransfer::Transfer] self def upload_files files.each do |file| upload_file( From 97b144303145e503e8e5d058e7caf558b973150f Mon Sep 17 00:00:00 2001 From: Arno Date: Fri, 22 Mar 2019 17:04:13 +0100 Subject: [PATCH 17/18] Add version to the required files --- lib/we_transfer.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/we_transfer.rb b/lib/we_transfer.rb index c929eee..9afb6ce 100644 --- a/lib/we_transfer.rb +++ b/lib/we_transfer.rb @@ -6,6 +6,7 @@ require 'ks' %w[ + version logging communicator client From 55414699d89f901d385d2f42e9b4ece26229140c Mon Sep 17 00:00:00 2001 From: Arno Date: Tue, 17 Sep 2019 16:34:44 +0200 Subject: [PATCH 18/18] Raise with more expressiveness --- lib/we_transfer/communicator.rb | 6 +++--- spec/we_transfer/communicator_spec.rb | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/we_transfer/communicator.rb b/lib/we_transfer/communicator.rb index a1a5161..9d96bdb 100644 --- a/lib/we_transfer/communicator.rb +++ b/lib/we_transfer/communicator.rb @@ -183,11 +183,11 @@ def ensure_ok_status!(response) logger.error(response) case response.status when 400..499 - raise WeTransfer::CommunicationError, JSON.parse(response.body)["message"] + raise WeTransfer::CommunicationError, "Response had a #{response.status} status code, message was '#{JSON.parse(response.body)["message"]}'." when 500..504 - raise WeTransfer::CommunicationError, "Response had a #{response.status} code, we could retry" + raise WeTransfer::CommunicationError, "Response had a #{response.status} status code, we could retry" end - raise WeTransfer::CommunicationError, "Response had a #{response.status} code, no idea what to do with that" + raise WeTransfer::CommunicationError, "Response had a #{response.status} status code, no idea what to do with that" end def authorize_if_no_bearer_token! diff --git a/spec/we_transfer/communicator_spec.rb b/spec/we_transfer/communicator_spec.rb index ad8b8f5..0b61db2 100644 --- a/spec/we_transfer/communicator_spec.rb +++ b/spec/we_transfer/communicator_spec.rb @@ -69,7 +69,7 @@ it "showing the error from the API to the user" do expect { subject.find_transfer('fake-transfer-id') } - .to raise_error('fake message') + .to raise_error(%r|Response had a \d\d\d status code, message was 'fake message'\.|) end end @@ -90,7 +90,7 @@ it "is telling we can try again" do expect { subject.find_transfer('fake-transfer-id') } - .to raise_error(%r|had a 501 code.*could retry|) + .to raise_error(%r|had a 501 status code.*could retry|) end context "everything else" do @@ -110,7 +110,7 @@ it "includes the error code in the message" do expect { subject.find_transfer('fake-transfer-id') } - .to raise_error(%r|had a 302 code|) + .to raise_error(%r|had a 302 status code|) end it "informs the user we have no way how to continue" do