Skip to content

Commit cbaa0be

Browse files
committed
Introduce Protocol::HTTP::Body::Streamable and Writable.
1 parent 205f165 commit cbaa0be

20 files changed

+428
-79
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2019-2023, by Samuel Williams.
5+
6+
require 'protocol/http/body/deflate'
7+
8+
module Protocol
9+
module HTTP
10+
module Body
11+
AWritableBody = Sus::Shared("a writable body") do
12+
it "can write and read data" do
13+
3.times do |i|
14+
body.write("Hello World #{i}")
15+
expect(body.read).to be == "Hello World #{i}"
16+
end
17+
end
18+
19+
it "can buffer data in order" do
20+
3.times do |i|
21+
body.write("Hello World #{i}")
22+
end
23+
24+
3.times do |i|
25+
expect(body.read).to be == "Hello World #{i}"
26+
end
27+
end
28+
29+
with '#join' do
30+
it "can join chunks" do
31+
3.times do |i|
32+
body.write("#{i}")
33+
end
34+
35+
body.close
36+
37+
expect(body.join).to be == "012"
38+
end
39+
end
40+
41+
with '#each' do
42+
it "can read all data in order" do
43+
3.times do |i|
44+
body.write("Hello World #{i}")
45+
end
46+
47+
body.close
48+
49+
3.times do |i|
50+
chunk = body.read
51+
expect(chunk).to be == "Hello World #{i}"
52+
end
53+
end
54+
55+
# it "can propagate failures" do
56+
# reactor.async do
57+
# expect do
58+
# body.each do |chunk|
59+
# raise RuntimeError.new("It was too big!")
60+
# end
61+
# end.to raise_exception(RuntimeError, message: be =~ /big/)
62+
# end
63+
64+
# expect{
65+
# body.write("Beep boop") # This will cause a failure.
66+
# ::Async::Task.current.yield
67+
# body.write("Beep boop") # This will fail.
68+
# }.to raise_exception(RuntimeError, message: be =~ /big/)
69+
# end
70+
71+
# it "can propagate failures in nested bodies" do
72+
# nested = ::Protocol::HTTP::Body::Deflate.for(body)
73+
74+
# reactor.async do
75+
# expect do
76+
# nested.each do |chunk|
77+
# raise RuntimeError.new("It was too big!")
78+
# end
79+
# end.to raise_exception(RuntimeError, message: be =~ /big/)
80+
# end
81+
82+
# expect{
83+
# body.write("Beep boop") # This will cause a failure.
84+
# ::Async::Task.current.yield
85+
# body.write("Beep boop") # This will fail.
86+
# }.to raise_exception(RuntimeError, message: be =~ /big/)
87+
# end
88+
89+
# it "will stop after finishing" do
90+
# output_task = reactor.async do
91+
# body.each do |chunk|
92+
# expect(chunk).to be == "Hello World!"
93+
# end
94+
# end
95+
96+
# body.write("Hello World!")
97+
# body.close
98+
99+
# expect(body).not.to be(:empty?)
100+
101+
# ::Async::Task.current.yield
102+
103+
# expect(output_task).to be(:finished?)
104+
# expect(body).to be(:empty?)
105+
# end
106+
end
107+
end
108+
end
109+
end
110+
end

lib/protocol/http/body.md

-9
This file was deleted.

lib/protocol/http/body/deflate.rb

-6
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,6 @@ def self.for(body, window_size = GZIP, level = DEFAULT_LEVEL)
6262
self.new(body, Zlib::Deflate.new(level, window_size))
6363
end
6464

65-
def stream?
66-
# We might want to revisit this design choice.
67-
# We could wrap the streaming body in a Deflate stream, but that would require an extra stream wrapper which we don't have right now. See also `Digestable#stream?`.
68-
false
69-
end
70-
7165
def read
7266
return if @stream.finished?
7367

lib/protocol/http/body/digestable.rb

-4
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,6 @@ def etag(weak: false)
3838
end
3939
end
4040

41-
def stream?
42-
false
43-
end
44-
4541
def read
4642
if chunk = super
4743
@digest.update(chunk)

lib/protocol/http/body/file.rb

+8-4
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,6 @@ def rewind
5656
@remaining = @length
5757
end
5858

59-
def stream?
60-
false
61-
end
62-
6359
def read
6460
if @remaining > 0
6561
amount = [@remaining, @block_size].min
@@ -72,6 +68,14 @@ def read
7268
end
7369
end
7470

71+
def stream?
72+
true
73+
end
74+
75+
def call(stream)
76+
IO.copy_stream(@file, stream)
77+
end
78+
7579
def join
7680
return "" if @remaining == 0
7781

lib/protocol/http/body/inflate.rb

-4
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,6 @@ def self.for(body, encoding = GZIP)
1515
self.new(body, Zlib::Inflate.new(encoding))
1616
end
1717

18-
def stream?
19-
false
20-
end
21-
2218
def read
2319
return if @stream.finished?
2420

lib/protocol/http/body/readable.rb

+15-8
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@
77
module Protocol
88
module HTTP
99
module Body
10-
# An interface for reading data from a body.
10+
# Represents a readable input streams.
1111
#
1212
# Typically, you'd override `#read` to return chunks of data.
13+
#
14+
# I n general, you read chunks of data from a body until it is empty and returns `nil`. Upon reading `nil`, the body is considered consumed and should not be read from again.
15+
#
16+
# Reading can also fail, for example if the body represents a streaming upload, and the connection is lost. In this case, the body will raise some kind of error.
17+
#
18+
# If you don't want to read from a stream, and instead want to close it immediately, you can call `close` on the body. If the body is already completely consumed, `close` will do nothing, but if there is still data to be read, it will cause the underlying stream to be reset (and possibly closed).
1319
class Readable
1420
# Close the stream immediately.
1521
def close(error = nil)
@@ -29,14 +35,19 @@ def ready?
2935
false
3036
end
3137

38+
# Whether the stream can be rewound using {rewind}.
3239
def rewindable?
3340
false
3441
end
3542

43+
# Rewind the stream to the beginning.
44+
# @returns [Boolean] Whether the stream was successfully rewound.
3645
def rewind
3746
false
3847
end
3948

49+
# The total length of the body, if known.
50+
# @returns [Integer | Nil] The total length of the body, or `nil` if the length is unknown.
4051
def length
4152
nil
4253
end
@@ -83,14 +94,10 @@ def join
8394
end
8495
end
8596

86-
# Should the internal mechanism prefer to use {call}?
87-
# @returns [Boolean]
88-
def stream?
89-
false
90-
end
91-
9297
# Write the body to the given stream.
9398
#
99+
# In some cases, the stream may also be readable, such as when hijacking an HTTP/1 connection. In that case, it may be acceptable to read and write to the stream directly.
100+
#
94101
# If the stream is not ready, it will be flushed after each chunk. Closes the stream when finished or if an error occurs.
95102
#
96103
def call(stream)
@@ -105,6 +112,7 @@ def call(stream)
105112
end
106113

107114
# Read all remaining chunks into a buffered body and close the underlying input.
115+
#
108116
# @returns [Buffered] The buffered body.
109117
def finish
110118
# Internally, this invokes `self.each` which then invokes `self.close`.
@@ -115,7 +123,6 @@ def as_json(...)
115123
{
116124
class: self.class.name,
117125
length: self.length,
118-
stream: self.stream?,
119126
ready: self.ready?,
120127
empty: self.empty?
121128
}

lib/protocol/http/body/rewindable.rb

-4
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,6 @@ def buffered
4343
Buffered.new(@chunks)
4444
end
4545

46-
def stream?
47-
false
48-
end
49-
5046
def read
5147
if @index < @chunks.size
5248
chunk = @chunks[@index]

lib/protocol/http/body/streamable.rb

+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2022, by Samuel Williams.
5+
6+
require_relative 'readable'
7+
require_relative 'stream'
8+
9+
module Protocol
10+
module HTTP
11+
module Body
12+
# A body that invokes a block that can read and write to a stream.
13+
#
14+
# In some cases, it's advantageous to directly read and write to the underlying stream if possible. For example, HTTP/1 upgrade requests, WebSockets, and similar. To handle that case, response bodies can implement `stream?` and return `true`. When `stream?` returns true, the body **should** be consumed by calling `call(stream)`. Server implementations may choose to always invoke `call(stream)` if it's efficient to do so. Bodies that don't support it will fall back to using `#each`.
15+
#
16+
# When invoking `call(stream)`, the stream can be read from and written to, and closed. However, the stream is only guaranteed to be open for the duration of the `call(stream)` call. Once the method returns, the stream **should** be closed by the server.
17+
class Streamable < Readable
18+
def initialize(block, input = nil)
19+
@block = block
20+
@input = input
21+
@output = nil
22+
end
23+
24+
attr :block
25+
26+
class Output
27+
def initialize(input, block)
28+
stream = Stream.new(input, self)
29+
30+
@from = nil
31+
32+
@fiber = Fiber.new do |from|
33+
@from = from
34+
block.call(stream)
35+
@fiber = nil
36+
end
37+
end
38+
39+
def write(chunk)
40+
if from = @from
41+
@from = nil
42+
@from = from.transfer(chunk)
43+
else
44+
raise RuntimeError, "Stream is not being read!"
45+
end
46+
end
47+
48+
def close
49+
@fiber = nil
50+
51+
if from = @from
52+
@from = nil
53+
from.transfer(nil)
54+
end
55+
end
56+
57+
def read
58+
raise RuntimeError, "Stream is already being read!" if @from
59+
60+
@fiber&.transfer(Fiber.current)
61+
end
62+
end
63+
64+
# Invokes the block in a fiber which yields chunks when they are available.
65+
def read
66+
@output ||= Output.new(@input, @block)
67+
68+
return @output.read
69+
end
70+
71+
def stream?
72+
true
73+
end
74+
75+
def call(stream)
76+
raise "Streaming body has already been read!" if @output
77+
78+
@block.call(stream)
79+
rescue => error
80+
raise
81+
ensure
82+
self.close(error)
83+
end
84+
end
85+
end
86+
end
87+
end

lib/protocol/http/body/wrapper.rb

+5-8
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ def rewindable?
5050
@body.rewindable?
5151
end
5252

53+
def stream?
54+
# Most wrappers are not streamable by default.
55+
false
56+
end
57+
5358
def length
5459
@body.length
5560
end
@@ -73,14 +78,6 @@ def to_json(...)
7378
def inspect
7479
@body.inspect
7580
end
76-
77-
def stream?
78-
@body.stream?
79-
end
80-
81-
def call(stream)
82-
@body.call(stream)
83-
end
8481
end
8582
end
8683
end

0 commit comments

Comments
 (0)