Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable request body streaming with an IO object #409

Merged
merged 11 commits into from
May 19, 2017
2 changes: 1 addition & 1 deletion http.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Gem::Specification.new do |gem|
gem.required_ruby_version = ">= 2.0"

gem.add_runtime_dependency "http_parser.rb", "~> 0.6.0"
gem.add_runtime_dependency "http-form_data", "~> 1.0.1"
gem.add_runtime_dependency "http-form_data", ">= 2.0.0-pre2", "< 3"
gem.add_runtime_dependency "http-cookie", "~> 1.0"
gem.add_runtime_dependency "addressable", "~> 2.3"

Expand Down
5 changes: 2 additions & 3 deletions lib/http/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,8 @@ def make_request_body(opts, headers)
opts.body
when opts.form
form = HTTP::FormData.create opts.form
headers[Headers::CONTENT_TYPE] ||= form.content_type
headers[Headers::CONTENT_LENGTH] ||= form.content_length
form.to_s
headers[Headers::CONTENT_TYPE] ||= form.content_type
form
when opts.json
body = MimeType[:json].encode opts.json
headers[Headers::CONTENT_TYPE] ||= "application/json; charset=#{body.encoding.name}"
Expand Down
2 changes: 1 addition & 1 deletion lib/http/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class UnsupportedSchemeError < RequestError; end
# @option opts [HTTP::URI, #to_s] :uri
# @option opts [Hash] :headers
# @option opts [Hash] :proxy
# @option opts [String] :body
# @option opts [String, Enumerable, IO, nil] :body
def initialize(opts)
@verb = opts.fetch(:verb).to_s.downcase.to_sym
@uri = normalize_uri(opts.fetch(:uri))
Expand Down
60 changes: 60 additions & 0 deletions lib/http/request/body.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

module HTTP
class Request
class Body
# Maximum chunk size used for reading content of an IO
BUFFER_SIZE = Connection::BUFFER_SIZE

def initialize(body)
@body = body

validate_body_type!
end

# Returns size which should be used for the "Content-Length" header.
#
# @return [Integer]
def size
if @body.is_a?(String)
@body.bytesize
elsif @body.respond_to?(:read)
raise RequestError, "IO object must respond to #size" unless @body.respond_to?(:size)
@body.size
elsif @body.nil?
0
else
raise RequestError, "cannot determine size of body: #{@body.inspect}"
end
end

# Yields chunks of content to be streamed to the request body.
#
# @yieldparam [String]
def each
return enum_for(__method__) unless block_given?

if @body.is_a?(String)
yield @body
elsif @body.respond_to?(:read)
while (data = @body.read(BUFFER_SIZE))
yield data
end
elsif @body.is_a?(Enumerable)
@body.each { |chunk| yield chunk }
end
end

private

def validate_body_type!
return if @body.is_a?(String)
return if @body.respond_to?(:read)
return if @body.is_a?(Enumerable)
return if @body.nil?

raise RequestError, "body of wrong type: #{@body.class}"
end
end
end
end
59 changes: 30 additions & 29 deletions lib/http/request/writer.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true
require "http/headers"
require "http/request/body"

module HTTP
class Request
Expand All @@ -16,16 +17,11 @@ class Writer
# End of a chunked transfer
CHUNKED_END = "#{ZERO}#{CRLF}#{CRLF}".freeze

# Types valid to be used as body source
VALID_BODY_TYPES = [String, NilClass, Enumerable].freeze

def initialize(socket, body, headers, headline)
@body = body
@body = Body.new(body)
@socket = socket
@headers = headers
@request_header = [headline]

validate_body_type!
end

# Adds headers to the request header from the headers array
Expand All @@ -51,13 +47,9 @@ def connect_through_proxy
# Adds the headers to the header array for the given request body we are working
# with
def add_body_type_headers
if @body.is_a?(String) && !@headers[Headers::CONTENT_LENGTH]
@request_header << "#{Headers::CONTENT_LENGTH}: #{@body.bytesize}"
elsif @body.nil? && !@headers[Headers::CONTENT_LENGTH]
@request_header << "#{Headers::CONTENT_LENGTH}: 0"
elsif @body.is_a?(Enumerable) && CHUNKED != @headers[Headers::TRANSFER_ENCODING]
raise(RequestError, "invalid transfer encoding")
end
return if @headers[Headers::CONTENT_LENGTH] || chunked?

@request_header << "#{Headers::CONTENT_LENGTH}: #{@body.size}"
end

# Joins the headers specified in the request into a correctly formatted
Expand All @@ -70,28 +62,42 @@ def join_headers

def send_request
headers = join_headers
chunks = @body.each

# It's important to send the request in a single write call when
# possible in order to play nicely with Nagle's algorithm. Making
# two writes in a row triggers a pathological case where Nagle is
# expecting a third write that never happens.
case @body
when NilClass
write(headers)
when String
write(headers << @body)
when Enumerable
begin
first_chunk = encode_chunk(chunks.next)
write(headers << first_chunk)
rescue StopIteration
write(headers)
end

@body.each do |chunk|
write(chunk.bytesize.to_s(16) << CRLF << chunk << CRLF)
end
# Kernel#loop automatically rescues StopIteration
loop do
data = encode_chunk(chunks.next)
write(data)
end

write(CHUNKED_END)
else raise TypeError, "invalid body type: #{@body.class}"
write(CHUNKED_END) if chunked?
end

# Returns the chunk encoded for to the specified "Transfer-Encoding" header.
def encode_chunk(chunk)
if chunked?
chunk.bytesize.to_s(16) << CRLF << chunk << CRLF
else
chunk
end
end

# Returns true if the request should be sent in chunked encoding.
def chunked?
@headers[Headers::TRANSFER_ENCODING] == CHUNKED
end

private

def write(data)
Expand All @@ -101,11 +107,6 @@ def write(data)
data = data.byteslice(length..-1)
end
end

def validate_body_type!
return if VALID_BODY_TYPES.any? { |type| @body.is_a? type }
raise RequestError, "body of wrong type: #{@body.class}"
end
end
end
end
26 changes: 26 additions & 0 deletions spec/lib/http/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,32 @@ def simple_response(body, status = 200)
end
end

describe "passing multipart form data" do
it "creates url encoded form data object" do
client = HTTP::Client.new
allow(client).to receive(:perform)

expect(HTTP::Request).to receive(:new) do |opts|
expect(opts[:body]).to be_a(HTTP::FormData::Urlencoded)
expect(opts[:body].to_s).to eq "foo=bar"
end

client.get("http://example.com/", :form => {:foo => "bar"})
end

it "creates multipart form data object" do
client = HTTP::Client.new
allow(client).to receive(:perform)

expect(HTTP::Request).to receive(:new) do |opts|
expect(opts[:body]).to be_a(HTTP::FormData::Multipart)
expect(opts[:body].to_s).to include("content")
end

client.get("http://example.com/", :form => {:foo => HTTP::FormData::Part.new("content")})
end
end

describe "passing json" do
it "encodes given object" do
client = HTTP::Client.new
Expand Down
Loading