Skip to content

Commit

Permalink
Make all components into IO objects
Browse files Browse the repository at this point in the history
By changing all components to use an IO object as a base, we can
implement a common IO interface for all components, which delegates to
the underlying IO object.

This enables streaming multipart data into the request body, avoiding
loading the whole multipart data into memory when File parts are backed
by File objects.

See httprb/http#409 for the new streaming API.
  • Loading branch information
janko authored and ixti committed May 8, 2017
1 parent bbcdbd8 commit 0d05356
Show file tree
Hide file tree
Showing 11 changed files with 373 additions and 108 deletions.
57 changes: 57 additions & 0 deletions lib/http/form_data/composite_io.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

module HTTP
module FormData
# Provides IO interface across multiple IO files.
class CompositeIO
# @param [Array<IO>] ios Array of IO objects
def initialize(*ios)
@ios = ios.flatten
@index = 0
end

# Reads and returns list of
#
# @param [Integer] length Number of bytes to retrieve
# @param [String] outbuf String to be replaced with retrieved data
#
# @return [String]
def read(length = nil, outbuf = nil)
outbuf = outbuf.to_s.replace("")

while current_io
data = current_io.read(length)
outbuf << data.to_s
length -= data.to_s.length if length

break if length == 0

advance_io
end

return nil if length && outbuf.empty?

outbuf
end

def rewind
@ios.each(&:rewind)
@index = 0
end

def size
@size ||= @ios.map(&:size).inject(0, :+)
end

private

def current_io
@ios[@index]
end

def advance_io
@index += 1
end
end
end
end
14 changes: 0 additions & 14 deletions lib/http/form_data/file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,6 @@ def initialize(path_or_io, opts = {})
end
end
end

# Returns content size.
#
# @return [Integer]
def size
@io.size
end

# Returns content of the IO.
#
# @return [String]
def to_s
@io.read
end
end
end
end
37 changes: 14 additions & 23 deletions lib/http/form_data/multipart.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
# frozen_string_literal: true

require "securerandom"
require "stringio"

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| [StringIO.new(glue), part] },
StringIO.new(tail),
)
end

# Returns MIME type to be used for HTTP request `Content-Type` header.
Expand All @@ -34,30 +36,19 @@ def content_type
#
# @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
size
end

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
Expand Down
59 changes: 28 additions & 31 deletions lib/http/form_data/multipart/param.rb
Original file line number Diff line number Diff line change
@@ -1,34 +1,18 @@
# frozen_string_literal: true

require "stringio"

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("; ")

@header = "Content-Disposition: form-data; #{parameters}"
include Readable

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
#
Expand All @@ -44,15 +28,28 @@ 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)
part =
if value.is_a?(FormData::Part)
value
else
FormData::Part.new(value)
end

# Calculates size of a part (headers + body).
#
# @return [Integer]
def size
@header.bytesize + (CRLF.bytesize * 2) + @part.size
parameters = { :name => name.to_s }
parameters[:filename] = part.filename if part.filename
parameters = parameters.map { |k, v| "#{k}=#{v.inspect}" }.join("; ")

header = String.new # rubocop:disable String/EmptyLiteral
header << "Content-Disposition: form-data; #{parameters}#{CRLF}"
header << "Content-Type: #{part.content_type}#{CRLF}" if part.content_type
header << CRLF

footer = CRLF.dup

@io = CompositeIO.new(StringIO.new(header), part, StringIO.new(footer))
end

# Flattens given `data` Hash into an array of `Param`'s.
Expand Down
22 changes: 7 additions & 15 deletions lib/http/form_data/part.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
38 changes: 38 additions & 0 deletions lib/http/form_data/readable.rb
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions spec/lib/http/form_data/composite_io_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# frozen_string_literal: true

RSpec.describe HTTP::FormData::CompositeIO do
let(:ios) { ["Hello", " ", "", "world", "!"].map { |string| StringIO.new(string) } }
subject(:composite_io) { HTTP::FormData::CompositeIO.new *ios }

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
Loading

0 comments on commit 0d05356

Please sign in to comment.