Skip to content

Commit 4445835

Browse files
authored
Merge pull request #409 from janko-m/enable-request-body-streaming
Enable request body streaming with an IO object
2 parents 823c7c2 + bb4479f commit 4445835

9 files changed

Lines changed: 380 additions & 57 deletions

File tree

http.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Gem::Specification.new do |gem|
2626
gem.required_ruby_version = ">= 2.0"
2727

2828
gem.add_runtime_dependency "http_parser.rb", "~> 0.6.0"
29-
gem.add_runtime_dependency "http-form_data", "~> 1.0.1"
29+
gem.add_runtime_dependency "http-form_data", ">= 2.0.0-pre2", "< 3"
3030
gem.add_runtime_dependency "http-cookie", "~> 1.0"
3131
gem.add_runtime_dependency "addressable", "~> 2.3"
3232

lib/http/client.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,8 @@ def make_request_body(opts, headers)
157157
opts.body
158158
when opts.form
159159
form = HTTP::FormData.create opts.form
160-
headers[Headers::CONTENT_TYPE] ||= form.content_type
161-
headers[Headers::CONTENT_LENGTH] ||= form.content_length
162-
form.to_s
160+
headers[Headers::CONTENT_TYPE] ||= form.content_type
161+
form
163162
when opts.json
164163
body = MimeType[:json].encode opts.json
165164
headers[Headers::CONTENT_TYPE] ||= "application/json; charset=#{body.encoding.name}"

lib/http/request.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ class UnsupportedSchemeError < RequestError; end
7474
# @option opts [HTTP::URI, #to_s] :uri
7575
# @option opts [Hash] :headers
7676
# @option opts [Hash] :proxy
77-
# @option opts [String] :body
77+
# @option opts [String, Enumerable, IO, nil] :body
7878
def initialize(opts)
7979
@verb = opts.fetch(:verb).to_s.downcase.to_sym
8080
@uri = normalize_uri(opts.fetch(:uri))

lib/http/request/body.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
module HTTP
4+
class Request
5+
class Body
6+
# Maximum chunk size used for reading content of an IO
7+
BUFFER_SIZE = Connection::BUFFER_SIZE
8+
9+
def initialize(body)
10+
@body = body
11+
12+
validate_body_type!
13+
end
14+
15+
# Returns size which should be used for the "Content-Length" header.
16+
#
17+
# @return [Integer]
18+
def size
19+
if @body.is_a?(String)
20+
@body.bytesize
21+
elsif @body.respond_to?(:read)
22+
raise RequestError, "IO object must respond to #size" unless @body.respond_to?(:size)
23+
@body.size
24+
elsif @body.nil?
25+
0
26+
else
27+
raise RequestError, "cannot determine size of body: #{@body.inspect}"
28+
end
29+
end
30+
31+
# Yields chunks of content to be streamed to the request body.
32+
#
33+
# @yieldparam [String]
34+
def each
35+
return enum_for(__method__) unless block_given?
36+
37+
if @body.is_a?(String)
38+
yield @body
39+
elsif @body.respond_to?(:read)
40+
while (data = @body.read(BUFFER_SIZE))
41+
yield data
42+
end
43+
elsif @body.is_a?(Enumerable)
44+
@body.each { |chunk| yield chunk }
45+
end
46+
end
47+
48+
private
49+
50+
def validate_body_type!
51+
return if @body.is_a?(String)
52+
return if @body.respond_to?(:read)
53+
return if @body.is_a?(Enumerable)
54+
return if @body.nil?
55+
56+
raise RequestError, "body of wrong type: #{@body.class}"
57+
end
58+
end
59+
end
60+
end

lib/http/request/writer.rb

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22
require "http/headers"
3+
require "http/request/body"
34

45
module HTTP
56
class Request
@@ -16,16 +17,11 @@ class Writer
1617
# End of a chunked transfer
1718
CHUNKED_END = "#{ZERO}#{CRLF}#{CRLF}".freeze
1819

19-
# Types valid to be used as body source
20-
VALID_BODY_TYPES = [String, NilClass, Enumerable].freeze
21-
2220
def initialize(socket, body, headers, headline)
23-
@body = body
21+
@body = Body.new(body)
2422
@socket = socket
2523
@headers = headers
2624
@request_header = [headline]
27-
28-
validate_body_type!
2925
end
3026

3127
# Adds headers to the request header from the headers array
@@ -51,13 +47,9 @@ def connect_through_proxy
5147
# Adds the headers to the header array for the given request body we are working
5248
# with
5349
def add_body_type_headers
54-
if @body.is_a?(String) && !@headers[Headers::CONTENT_LENGTH]
55-
@request_header << "#{Headers::CONTENT_LENGTH}: #{@body.bytesize}"
56-
elsif @body.nil? && !@headers[Headers::CONTENT_LENGTH]
57-
@request_header << "#{Headers::CONTENT_LENGTH}: 0"
58-
elsif @body.is_a?(Enumerable) && CHUNKED != @headers[Headers::TRANSFER_ENCODING]
59-
raise(RequestError, "invalid transfer encoding")
60-
end
50+
return if @headers[Headers::CONTENT_LENGTH] || chunked?
51+
52+
@request_header << "#{Headers::CONTENT_LENGTH}: #{@body.size}"
6153
end
6254

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

7163
def send_request
7264
headers = join_headers
65+
chunks = @body.each
7366

7467
# It's important to send the request in a single write call when
7568
# possible in order to play nicely with Nagle's algorithm. Making
7669
# two writes in a row triggers a pathological case where Nagle is
7770
# expecting a third write that never happens.
78-
case @body
79-
when NilClass
80-
write(headers)
81-
when String
82-
write(headers << @body)
83-
when Enumerable
71+
begin
72+
first_chunk = encode_chunk(chunks.next)
73+
write(headers << first_chunk)
74+
rescue StopIteration
8475
write(headers)
76+
end
8577

86-
@body.each do |chunk|
87-
write(chunk.bytesize.to_s(16) << CRLF << chunk << CRLF)
88-
end
78+
# Kernel#loop automatically rescues StopIteration
79+
loop do
80+
data = encode_chunk(chunks.next)
81+
write(data)
82+
end
8983

90-
write(CHUNKED_END)
91-
else raise TypeError, "invalid body type: #{@body.class}"
84+
write(CHUNKED_END) if chunked?
85+
end
86+
87+
# Returns the chunk encoded for to the specified "Transfer-Encoding" header.
88+
def encode_chunk(chunk)
89+
if chunked?
90+
chunk.bytesize.to_s(16) << CRLF << chunk << CRLF
91+
else
92+
chunk
9293
end
9394
end
9495

96+
# Returns true if the request should be sent in chunked encoding.
97+
def chunked?
98+
@headers[Headers::TRANSFER_ENCODING] == CHUNKED
99+
end
100+
95101
private
96102

97103
def write(data)
@@ -101,11 +107,6 @@ def write(data)
101107
data = data.byteslice(length..-1)
102108
end
103109
end
104-
105-
def validate_body_type!
106-
return if VALID_BODY_TYPES.any? { |type| @body.is_a? type }
107-
raise RequestError, "body of wrong type: #{@body.class}"
108-
end
109110
end
110111
end
111112
end

spec/lib/http/client_spec.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,32 @@ def simple_response(body, status = 200)
158158
end
159159
end
160160

161+
describe "passing multipart form data" do
162+
it "creates url encoded form data object" do
163+
client = HTTP::Client.new
164+
allow(client).to receive(:perform)
165+
166+
expect(HTTP::Request).to receive(:new) do |opts|
167+
expect(opts[:body]).to be_a(HTTP::FormData::Urlencoded)
168+
expect(opts[:body].to_s).to eq "foo=bar"
169+
end
170+
171+
client.get("http://example.com/", :form => {:foo => "bar"})
172+
end
173+
174+
it "creates multipart form data object" do
175+
client = HTTP::Client.new
176+
allow(client).to receive(:perform)
177+
178+
expect(HTTP::Request).to receive(:new) do |opts|
179+
expect(opts[:body]).to be_a(HTTP::FormData::Multipart)
180+
expect(opts[:body].to_s).to include("content")
181+
end
182+
183+
client.get("http://example.com/", :form => {:foo => HTTP::FormData::Part.new("content")})
184+
end
185+
end
186+
161187
describe "passing json" do
162188
it "encodes given object" do
163189
client = HTTP::Client.new

0 commit comments

Comments
 (0)