diff --git a/.rubocop.yml b/.rubocop.yml index 40337cf..9302fb3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,6 +13,12 @@ Metrics/MethodLength: CountComments: false Max: 15 +Metrics/CyclomaticComplexity: + Max: 8 + +Metrics/PerceivedComplexity: + Max: 8 + ## Styles ###################################################################### Style/AlignParameters: diff --git a/lib/http/form_data/composite_io.rb b/lib/http/form_data/composite_io.rb new file mode 100644 index 0000000..dbcc5ee --- /dev/null +++ b/lib/http/form_data/composite_io.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "stringio" + +module HTTP + module FormData + # Provides IO interface across multiple IO objects. + class CompositeIO + # @param [Array] ios Array of IO objects + def initialize(ios) + ios = ios.map do |io| + if io.is_a?(String) + StringIO.new(io) + elsif io.respond_to?(:read) + io + else + fail ArgumentError, "#{io.inspect} is neither a String nor an IO object" + end + end + + @ios = ios + @index = 0 + @buffer = String.new + end + + # Reads and returns partial content acrosss multiple IO objects. + # + # @param [Integer] length Number of bytes to retrieve + # @param [String] outbuf String to be replaced with retrieved data + # + # @return [String, nil] + def read(length = nil, outbuf = nil) + outbuf = outbuf.to_s.replace("") + + while current_io + current_io.read(length, @buffer) + outbuf << @buffer + length -= @buffer.length if length + + break if length && length.zero? + + advance_io + end + + outbuf unless length && outbuf.empty? + end + + # Returns sum of all IO sizes. + def size + @size ||= @ios.map(&:size).inject(0, :+) + end + + # Rewinds all IO objects and set cursor to the first IO object. + def rewind + @ios.each(&:rewind) + @index = 0 + end + + private + + # Returns IO object under the cursor. + def current_io + @ios[@index] + end + + # Advances cursor to the next IO object. + def advance_io + @index += 1 + end + end + end +end diff --git a/lib/http/form_data/file.rb b/lib/http/form_data/file.rb index a39bee7..85cfc8e 100644 --- a/lib/http/form_data/file.rb +++ b/lib/http/form_data/file.rb @@ -26,15 +26,14 @@ class File < Part alias mime_type content_type # @see DEFAULT_MIME - # @param [String, StringIO, File] file_or_io Filename or IO instance. + # @param [String, Pathname, IO] path_or_io Filename or IO instance. # @param [#to_h] opts # @option opts [#to_s] :content_type (DEFAULT_MIME) # Value of Content-Type header # @option opts [#to_s] :filename - # When `file` is a String, defaults to basename of `file`. - # When `file` is a File, defaults to basename of `file`. - # When `file` is a StringIO, defaults to `"stream-{object_id}"` - def initialize(file_or_io, opts = {}) + # When `path_or_io` is a String, Pathname or File, defaults to basename. + # When `path_or_io` is a IO, defaults to `"stream-{object_id}"`. + def initialize(path_or_io, opts = {}) opts = FormData.ensure_hash(opts) if opts.key? :mime_type @@ -42,40 +41,28 @@ def initialize(file_or_io, opts = {}) opts[:content_type] = opts[:mime_type] end - @file_or_io = file_or_io + @io = make_io(path_or_io) @content_type = opts.fetch(:content_type, DEFAULT_MIME).to_s - @filename = opts.fetch :filename do - case file_or_io - when String then ::File.basename file_or_io - when ::File then ::File.basename file_or_io.path - else "stream-#{file_or_io.object_id}" - end - end + @filename = opts.fetch(:filename, filename_for(@io)) end - # Returns content size. - # - # @return [Integer] - def size - with_io(&:size) - end + private - # Returns content of a file of IO. - # - # @return [String] - def to_s - with_io(&:read) + def make_io(path_or_io) + if path_or_io.is_a?(String) + ::File.open(path_or_io, :binmode => true) + elsif defined?(Pathname) && path_or_io.is_a?(Pathname) + path_or_io.open(:binmode => true) + else + path_or_io + end end - private - - # @yield [io] Gives IO instance to the block - # @return result of yielded block - def with_io - if @file_or_io.is_a?(::File) || @file_or_io.is_a?(StringIO) - yield @file_or_io + def filename_for(io) + if io.respond_to?(:path) + ::File.basename io.path else - ::File.open(@file_or_io, "rb") { |io| yield io } + "stream-#{io.object_id}" end end end diff --git a/lib/http/form_data/multipart.rb b/lib/http/form_data/multipart.rb index 27d862d..d51f12d 100644 --- a/lib/http/form_data/multipart.rb +++ b/lib/http/form_data/multipart.rb @@ -3,23 +3,21 @@ require "securerandom" require "http/form_data/multipart/param" +require "http/form_data/readable" +require "http/form_data/composite_io" module HTTP module FormData # `multipart/form-data` form data. class Multipart + include Readable + # @param [#to_h, Hash] data form data key-value Hash def initialize(data) - @parts = Param.coerce FormData.ensure_hash data - @boundary = (Array.new(21, "-") << SecureRandom.hex(21)).join("") - @content_length = nil - end + parts = Param.coerce FormData.ensure_hash data - # Returns content to be used for HTTP request body. - # - # @return [String] - def to_s - head + @parts.map(&:to_s).join(glue) + tail + @boundary = ("-" * 21) << SecureRandom.hex(21) + @io = CompositeIO.new [*parts.flat_map { |part| [glue, part] }, tail] end # Returns MIME type to be used for HTTP request `Content-Type` header. @@ -33,31 +31,18 @@ def content_type # `Content-Length` header. # # @return [Integer] - def content_length - unless @content_length - @content_length = head.bytesize + tail.bytesize - @content_length += @parts.map(&:size).reduce(:+) - @content_length += (glue.bytesize * (@parts.count - 1)) - end - - @content_length - end + alias content_length size private - # @return [String] - def head - @head ||= "--#{@boundary}#{CRLF}" - end - # @return [String] def glue - @glue ||= "#{CRLF}--#{@boundary}#{CRLF}" + @glue ||= "--#{@boundary}#{CRLF}" end # @return [String] def tail - @tail ||= "#{CRLF}--#{@boundary}--" + @tail ||= "--#{@boundary}--" end end end diff --git a/lib/http/form_data/multipart/param.rb b/lib/http/form_data/multipart/param.rb index d49d13f..080b9e2 100644 --- a/lib/http/form_data/multipart/param.rb +++ b/lib/http/form_data/multipart/param.rb @@ -1,34 +1,16 @@ # frozen_string_literal: true +require "http/form_data/readable" +require "http/form_data/composite_io" + module HTTP module FormData class Multipart # Utility class to represent multi-part chunks class Param - # @param [#to_s] name - # @param [FormData::File, FormData::Part, #to_s] value - def initialize(name, value) - @name = name.to_s - - @part = - if value.is_a?(FormData::Part) - value - else - FormData::Part.new(value) - end - - parameters = { :name => @name } - parameters[:filename] = @part.filename if @part.filename - parameters = parameters.map { |k, v| "#{k}=#{v.inspect}" }.join("; ") + include Readable - @header = "Content-Disposition: form-data; #{parameters}" - - return unless @part.content_type - - @header += "#{CRLF}Content-Type: #{@part.content_type}" - end - - # Returns body part with headers and data. + # Initializes body part with headers and data. # # @example With {FormData::File} value # @@ -44,15 +26,19 @@ def initialize(name, value) # ixti # # @return [String] - def to_s - "#{@header}#{CRLF * 2}#{@part}" - end + # @param [#to_s] name + # @param [FormData::File, FormData::Part, #to_s] value + def initialize(name, value) + @name = name.to_s - # Calculates size of a part (headers + body). - # - # @return [Integer] - def size - @header.bytesize + (CRLF.bytesize * 2) + @part.size + @part = + if value.is_a?(FormData::Part) + value + else + FormData::Part.new(value) + end + + @io = CompositeIO.new [header, @part, footer] end # Flattens given `data` Hash into an array of `Param`'s. @@ -72,6 +58,34 @@ def self.coerce(data) params end + + private + + def header + header = String.new + header << "Content-Disposition: form-data; #{parameters}#{CRLF}" + header << "Content-Type: #{content_type}#{CRLF}" if content_type + header << CRLF + header + end + + def parameters + parameters = { :name => @name } + parameters[:filename] = filename if filename + parameters.map { |k, v| "#{k}=#{v.inspect}" }.join("; ") + end + + def content_type + @part.content_type + end + + def filename + @part.filename + end + + def footer + CRLF.dup + end end end end diff --git a/lib/http/form_data/part.rb b/lib/http/form_data/part.rb index 9b4a5e9..a1bede8 100644 --- a/lib/http/form_data/part.rb +++ b/lib/http/form_data/part.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +require "stringio" + +require "http/form_data/readable" + module HTTP module FormData # Represents a body part of multipart/form-data request. @@ -9,30 +13,18 @@ module FormData # body = "Message" # FormData::Part.new body, :content_type => 'foobar.txt; charset="UTF-8"' class Part + include Readable + attr_reader :content_type, :filename # @param [#to_s] body # @param [String] content_type Value of Content-Type header # @param [String] filename Value of filename parameter def initialize(body, content_type: nil, filename: nil) - @body = body.to_s + @io = StringIO.new(body.to_s) @content_type = content_type @filename = filename end - - # Returns content size. - # - # @return [Integer] - def size - @body.bytesize - end - - # Returns content of a file of IO. - # - # @return [String] - def to_s - @body - end end end end diff --git a/lib/http/form_data/readable.rb b/lib/http/form_data/readable.rb new file mode 100644 index 0000000..f85e77d --- /dev/null +++ b/lib/http/form_data/readable.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module HTTP + module FormData + # Common behaviour for objects defined by an IO object. + module Readable + # Returns IO content. + # + # @return [String] + def to_s + rewind + read + end + + # Reads and returns part of IO content. + # + # @param [Integer] length Number of bytes to retrieve + # @param [String] outbuf String to be replaced with retrieved data + # + # @return [String, nil] + def read(length = nil, outbuf = nil) + @io.read(length, outbuf) + end + + # Returns IO size. + # + # @return [Integer] + def size + @io.size + end + + # Rewinds the IO. + def rewind + @io.rewind + end + end + end +end diff --git a/spec/lib/http/form_data/composite_io_spec.rb b/spec/lib/http/form_data/composite_io_spec.rb new file mode 100644 index 0000000..edc4dd2 --- /dev/null +++ b/spec/lib/http/form_data/composite_io_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +RSpec.describe HTTP::FormData::CompositeIO do + let(:ios) { ["Hello", " ", "", "world", "!"].map { |s| StringIO.new(s) } } + subject(:composite_io) { HTTP::FormData::CompositeIO.new(ios) } + + describe "#initialize" do + it "accepts IOs and strings" do + composite_io = HTTP::FormData::CompositeIO.new ["Hello ", StringIO.new("world!")] + expect(composite_io.read).to eq "Hello world!" + end + + it "fails if an IO is neither a String nor an IO" do + expect { HTTP::FormData::CompositeIO.new [:hello, :world] } + .to raise_error(ArgumentError) + end + end + + describe "#read" do + it "reads all data" do + expect(composite_io.read).to eq "Hello world!" + end + + it "reads partial data" do + expect(composite_io.read(3)).to eq "Hel" + expect(composite_io.read(2)).to eq "lo" + expect(composite_io.read(1)).to eq " " + expect(composite_io.read(6)).to eq "world!" + end + + it "returns empty string when no data was retrieved" do + composite_io.read + expect(composite_io.read).to eq "" + end + + it "returns nil when no partial data was retrieved" do + composite_io.read + expect(composite_io.read(3)).to eq nil + end + + it "reads partial data with a buffer" do + outbuf = String.new + expect(composite_io.read(3, outbuf)).to eq "Hel" + expect(composite_io.read(2, outbuf)).to eq "lo" + expect(composite_io.read(1, outbuf)).to eq " " + expect(composite_io.read(6, outbuf)).to eq "world!" + end + + it "fills the buffer with retrieved content" do + outbuf = String.new + composite_io.read(3, outbuf) + expect(outbuf).to eq "Hel" + composite_io.read(2, outbuf) + expect(outbuf).to eq "lo" + composite_io.read(1, outbuf) + expect(outbuf).to eq " " + composite_io.read(6, outbuf) + expect(outbuf).to eq "world!" + end + + it "returns nil when no partial data was retrieved with a buffer" do + outbuf = String.new("content") + composite_io.read + expect(composite_io.read(3, outbuf)).to eq nil + expect(outbuf).to eq "" + end + end + + describe "#rewind" do + it "rewinds all IOs" do + composite_io.read + composite_io.rewind + expect(composite_io.read).to eq "Hello world!" + end + end + + describe "#size" do + it "returns sum of all IO sizes" do + expect(composite_io.size).to eq 12 + end + + it "returns 0 when there are no IOs" do + empty_composite_io = HTTP::FormData::CompositeIO.new [] + expect(empty_composite_io.size).to eq 0 + end + end +end diff --git a/spec/lib/http/form_data/file_spec.rb b/spec/lib/http/form_data/file_spec.rb index dbcda05..608b40d 100644 --- a/spec/lib/http/form_data/file_spec.rb +++ b/spec/lib/http/form_data/file_spec.rb @@ -12,9 +12,9 @@ it { is_expected.to eq fixture("the-http-gem.info").size } end - context "when file given as StringIO" do - let(:file) { StringIO.new "привет мир!" } - it { is_expected.to eq 20 } + context "when file given as a Pathname" do + let(:file) { fixture("the-http-gem.info") } + it { is_expected.to eq fixture("the-http-gem.info").size } end context "when file given as File" do @@ -22,6 +22,11 @@ after { file.close } it { is_expected.to eq fixture("the-http-gem.info").size } end + + context "when file given as IO" do + let(:file) { StringIO.new "привет мир!" } + it { is_expected.to eq 20 } + end end describe "#to_s" do @@ -32,16 +37,91 @@ it { is_expected.to eq fixture("the-http-gem.info").read(:mode => "rb") } end - context "when file given as StringIO" do + context "when file given as a Pathname" do + let(:file) { fixture("the-http-gem.info") } + it { is_expected.to eq fixture("the-http-gem.info").read(:mode => "rb") } + end + + context "when file given as File" do + let(:file) { fixture("the-http-gem.info").open("rb") } + after { file.close } + it { is_expected.to eq fixture("the-http-gem.info").read(:mode => "rb") } + end + + context "when file given as IO" do let(:file) { StringIO.new "привет мир!" } it { is_expected.to eq "привет мир!" } end + end + + describe "#read" do + subject { described_class.new(file, opts).read } + + context "when file given as a String" do + let(:file) { fixture("the-http-gem.info").to_s } + it { is_expected.to eq fixture("the-http-gem.info").read(:mode => "rb") } + end + + context "when file given as a Pathname" do + let(:file) { fixture("the-http-gem.info") } + it { is_expected.to eq fixture("the-http-gem.info").read(:mode => "rb") } + end context "when file given as File" do let(:file) { fixture("the-http-gem.info").open("rb") } after { file.close } it { is_expected.to eq fixture("the-http-gem.info").read(:mode => "rb") } end + + context "when file given as IO" do + let(:file) { StringIO.new "привет мир!" } + it { is_expected.to eq "привет мир!" } + end + end + + describe "#rewind" do + subject { described_class.new(file, opts) } + + context "when file given as a String" do + let(:file) { fixture("the-http-gem.info").to_s } + + it "rewinds the underlying IO object" do + content = subject.read + subject.rewind + expect(subject.read).to eq content + end + end + + context "when file given as a Pathname" do + let(:file) { fixture("the-http-gem.info") } + + it "rewinds the underlying IO object" do + content = subject.read + subject.rewind + expect(subject.read).to eq content + end + end + + context "when file given as File" do + let(:file) { fixture("the-http-gem.info").open("rb") } + after { file.close } + + it "rewinds the underlying IO object" do + content = subject.read + subject.rewind + expect(subject.read).to eq content + end + end + + context "when file given as IO" do + let(:file) { StringIO.new "привет мир!" } + + it "rewinds the underlying IO object" do + content = subject.read + subject.rewind + expect(subject.read).to eq content + end + end end describe "#filename" do @@ -58,10 +138,10 @@ end end - context "when file given as StringIO" do - let(:file) { StringIO.new } + context "when file given as a Pathname" do + let(:file) { fixture("the-http-gem.info") } - it { is_expected.to eq "stream-#{file.object_id}" } + it { is_expected.to eq ::File.basename file } context "and filename given with options" do let(:opts) { { :filename => "foobar.txt" } } @@ -80,6 +160,17 @@ it { is_expected.to eq "foobar.txt" } end end + + context "when file given as IO" do + let(:file) { StringIO.new } + + it { is_expected.to eq "stream-#{file.object_id}" } + + context "and filename given with options" do + let(:opts) { { :filename => "foobar.txt" } } + it { is_expected.to eq "foobar.txt" } + end + end end describe "#content_type" do diff --git a/spec/lib/http/form_data/multipart_spec.rb b/spec/lib/http/form_data/multipart_spec.rb index bd95614..eb201c4 100644 --- a/spec/lib/http/form_data/multipart_spec.rb +++ b/spec/lib/http/form_data/multipart_spec.rb @@ -6,19 +6,6 @@ let(:boundary) { /-{21}[a-f0-9]{42}/ } subject(:form_data) { HTTP::FormData::Multipart.new params } - describe "#content_type" do - subject { form_data.content_type } - - let(:content_type) { %r{^multipart\/form-data; boundary=#{boundary}$} } - - it { is_expected.to match(content_type) } - end - - describe "#content_length" do - subject { form_data.content_length } - it { is_expected.to eq form_data.to_s.bytesize } - end - describe "#to_s" do def disposition(params) params = params.map { |k, v| "#{k}=#{v.inspect}" }.join("; ") @@ -36,14 +23,14 @@ def disposition(params) "#{crlf}bar#{crlf}", "--#{boundary_value}#{crlf}", "#{disposition 'name' => 'baz', 'filename' => file.filename}#{crlf}", - "Content-Type: #{file.mime_type}#{crlf}", + "Content-Type: #{file.content_type}#{crlf}", "#{crlf}#{file}#{crlf}", "--#{boundary_value}--" ].join("") end context "with filename set to nil" do - let(:part) { HTTP::FormData::Part.new("s", :filename => nil) } + let(:part) { HTTP::FormData::Part.new("s", :content_type => "mime/type") } let(:form_data) { HTTP::FormData::Multipart.new(:foo => part) } it "doesn't include a filename" do @@ -52,10 +39,60 @@ def disposition(params) expect(form_data.to_s).to eq [ "--#{boundary_value}#{crlf}", "#{disposition 'name' => 'foo'}#{crlf}", + "Content-Type: #{part.content_type}#{crlf}", "#{crlf}s#{crlf}", "--#{boundary_value}--" ].join("") end end + + context "with content type set to nil" do + let(:part) { HTTP::FormData::Part.new("s") } + let(:form_data) { HTTP::FormData::Multipart.new(:foo => part) } + + it "doesn't include a filename" do + boundary_value = form_data.content_type[/(#{boundary})$/, 1] + + expect(form_data.to_s).to eq [ + "--#{boundary_value}#{crlf}", + "#{disposition 'name' => 'foo'}#{crlf}", + "#{crlf}s#{crlf}", + "--#{boundary_value}--" + ].join("") + end + end + end + + describe "#size" do + it "returns bytesize of multipart data" do + expect(form_data.size).to eq form_data.to_s.bytesize + end + end + + describe "#read" do + it "returns multipart data" do + expect(form_data.read).to eq form_data.to_s + end + end + + describe "#rewind" do + it "rewinds the multipart data IO" do + form_data.read + form_data.rewind + expect(form_data.read).to eq form_data.to_s + end + end + + describe "#content_type" do + subject { form_data.content_type } + + let(:content_type) { %r{^multipart\/form-data; boundary=#{boundary}$} } + + it { is_expected.to match(content_type) } + end + + describe "#content_length" do + subject { form_data.content_length } + it { is_expected.to eq form_data.to_s.bytesize } end end diff --git a/spec/lib/http/form_data/part_spec.rb b/spec/lib/http/form_data/part_spec.rb index 32a5ce0..8c92b56 100644 --- a/spec/lib/http/form_data/part_spec.rb +++ b/spec/lib/http/form_data/part_spec.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true RSpec.describe HTTP::FormData::Part do - let(:body) { "" } - let(:opts) { {} } + let(:body) { "" } + let(:opts) { {} } + subject(:part) { HTTP::FormData::Part.new(body, opts) } describe "#size" do - subject { described_class.new(body, opts).size } + subject { part.size } context "when body given as a String" do let(:body) { "привет мир!" } @@ -14,7 +15,7 @@ end describe "#to_s" do - subject { described_class.new(body, opts).to_s } + subject! { part.to_s } context "when body given as String" do let(:body) { "привет мир!" } @@ -22,8 +23,29 @@ end end + describe "#read" do + subject { part.read } + + context "when body given as String" do + let(:body) { "привет мир!" } + it { is_expected.to eq "привет мир!" } + end + end + + describe "#rewind" do + context "when body given as String" do + let(:body) { "привет мир!" } + + it "rewinds the underlying IO object" do + part.read + part.rewind + expect(part.read).to eq "привет мир!" + end + end + end + describe "#filename" do - subject { described_class.new(body, opts).filename } + subject { part.filename } it { is_expected.to eq nil } @@ -34,7 +56,7 @@ end describe "#content_type" do - subject { described_class.new(body, opts).content_type } + subject { part.content_type } it { is_expected.to eq nil } diff --git a/spec/lib/http/form_data_spec.rb b/spec/lib/http/form_data_spec.rb index 9798ab8..f75d0e5 100644 --- a/spec/lib/http/form_data_spec.rb +++ b/spec/lib/http/form_data_spec.rb @@ -10,14 +10,14 @@ end context "when form has at least one file param" do - let(:gemspec) { HTTP::FormData::File.new "gemspec" } - let(:params) { { :foo => :bar, :baz => gemspec } } + let(:file) { HTTP::FormData::File.new(fixture("the-http-gem.info").to_s) } + let(:params) { { :foo => :bar, :baz => file } } it { is_expected.to be_a HTTP::FormData::Multipart } end context "when form has file in an array param" do - let(:gemspec) { HTTP::FormData::File.new "gemspec" } - let(:params) { { :foo => :bar, :baz => [gemspec] } } + let(:file) { HTTP::FormData::File.new(fixture("the-http-gem.info").to_s) } + let(:params) { { :foo => :bar, :baz => [file] } } it { is_expected.to be_a HTTP::FormData::Multipart } end end