Skip to content

Commit 1b1ccac

Browse files
committed
Copy Async::HTTP::Body::Writable and Protocol::Rack::Streaming.
1 parent 81f38b1 commit 1b1ccac

File tree

6 files changed

+569
-0
lines changed

6 files changed

+569
-0
lines changed
Lines changed: 110 additions & 0 deletions
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/streamable.rb

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
class Closed < StandardError
19+
end
20+
21+
def initialize(block, input = nil)
22+
@block = block
23+
@input = input
24+
@output = nil
25+
end
26+
27+
# Closing a stream indicates we are no longer interested in reading from it.
28+
def close(error = nil)
29+
if @input
30+
@input.close
31+
@input = nil
32+
end
33+
34+
if @output
35+
@output.close(error)
36+
end
37+
end
38+
39+
attr :block
40+
41+
class Output
42+
def initialize(input, block)
43+
stream = Stream.new(input, self)
44+
45+
@from = nil
46+
47+
@fiber = Fiber.new do |from|
48+
@from = from
49+
block.call(stream)
50+
rescue Closed
51+
# Ignore.
52+
ensure
53+
@fiber = nil
54+
55+
# No more chunks will be generated:
56+
if from = @from
57+
@from = nil
58+
from.transfer(nil)
59+
end
60+
end
61+
end
62+
63+
# Can be invoked by the block to write to the stream.
64+
def write(chunk)
65+
if from = @from
66+
@from = nil
67+
@from = from.transfer(chunk)
68+
else
69+
raise RuntimeError, "Stream is not being read!"
70+
end
71+
end
72+
73+
# Can be invoked by the block to close the stream.
74+
def close(error = nil)
75+
if from = @from
76+
@from = nil
77+
from.transfer(nil)
78+
elsif @fiber
79+
@fiber.raise(error || Closed)
80+
end
81+
end
82+
83+
def read
84+
raise RuntimeError, "Stream is already being read!" if @from
85+
86+
@fiber&.transfer(Fiber.current)
87+
end
88+
end
89+
90+
# Invokes the block in a fiber which yields chunks when they are available.
91+
def read
92+
@output ||= Output.new(@input, @block)
93+
94+
return @output.read
95+
end
96+
97+
def stream?
98+
true
99+
end
100+
101+
def call(stream)
102+
raise "Streaming body has already been read!" if @output
103+
104+
@block.call(stream)
105+
rescue => error
106+
raise
107+
ensure
108+
self.close(error)
109+
end
110+
end
111+
end
112+
end
113+
end

lib/protocol/http/body/writable.rb

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2018-2023, by Samuel Williams.
5+
6+
require_relative 'readable'
7+
8+
module Protocol
9+
module HTTP
10+
module Body
11+
# A dynamic body which you can write to and read from.
12+
class Writable < Readable
13+
class Closed < StandardError
14+
end
15+
16+
# @param [Integer] length The length of the response body if known.
17+
# @param [Async::Queue] queue Specify a different queue implementation, e.g. `Async::LimitedQueue.new(8)` to enable back-pressure streaming.
18+
def initialize(length = nil, queue: Thread::Queue.new)
19+
@queue = queue
20+
21+
@length = length
22+
23+
@count = 0
24+
25+
@finished = false
26+
27+
@closed = false
28+
@error = nil
29+
end
30+
31+
def length
32+
@length
33+
end
34+
35+
# Stop generating output; cause the next call to write to fail with the given error. Does not prevent existing chunks from being read. In other words, this indicates both that no more data will be or should be written to the body.
36+
def close(error = nil)
37+
unless @closed
38+
@queue.close
39+
40+
@closed = true
41+
@error = error
42+
end
43+
44+
super
45+
end
46+
47+
def closed?
48+
@closed
49+
end
50+
51+
def ready?
52+
!@queue.empty? || @queue.closed?
53+
end
54+
55+
# Has the producer called #finish and has the reader consumed the nil token?
56+
def empty?
57+
@queue.empty? && @queue.closed?
58+
end
59+
60+
# Read the next available chunk.
61+
def read
62+
@queue.pop
63+
end
64+
65+
# Write a single chunk to the body. Signal completion by calling `#finish`.
66+
def write(chunk)
67+
# If the reader breaks, the writer will break.
68+
# The inverse of this is less obvious (*)
69+
if @closed
70+
raise(@error || Closed)
71+
end
72+
73+
@count += 1
74+
@queue.push(chunk)
75+
end
76+
77+
alias << write
78+
79+
def inspect
80+
"\#<#{self.class} #{@count} chunks written, #{status}>"
81+
end
82+
83+
private
84+
85+
def status
86+
if @queue.empty?
87+
if @queue.closed?
88+
'closed'
89+
else
90+
'waiting'
91+
end
92+
else
93+
if @queue.closed?
94+
'closing'
95+
else
96+
'ready'
97+
end
98+
end
99+
end
100+
end
101+
end
102+
end
103+
end

releases.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Clarify behaviour of streaming bodies and copy `Protocol::Rack::Body::Streaming` to `Protocol::HTTP::Body::Streamable`.
6+
- Copy `Async::HTTP::Body::Writable` to `Protocol::HTTP::Body::Writable`.
7+
38
## v0.31.0
49

510
- Ensure chunks are flushed if required, when streaming.

0 commit comments

Comments
 (0)