biryani 0.0.9 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/README.md +2 -2
- data/example/echo.rb +2 -2
- data/example/hello_world.rb +2 -2
- data/lib/biryani/connection.rb +51 -40
- data/lib/biryani/connection_error.rb +1 -1
- data/lib/biryani/frame/goaway.rb +5 -3
- data/lib/biryani/http/error.rb +10 -0
- data/lib/biryani/http/request.rb +97 -0
- data/lib/biryani/http/response.rb +68 -0
- data/lib/biryani/http.rb +3 -0
- data/lib/biryani/stream.rb +3 -3
- data/lib/biryani/streams_context.rb +1 -1
- data/lib/biryani/version.rb +1 -1
- data/lib/biryani.rb +1 -3
- metadata +6 -5
- data/lib/biryani/error.rb +0 -8
- data/lib/biryani/http_request.rb +0 -95
- data/lib/biryani/http_response.rb +0 -66
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1aa3fb4dd49abcba22df142cd67d38c5eedc6c8beb6ca8fbcb130147c7e7506a
|
|
4
|
+
data.tar.gz: 54bd5a493c027047eeaada80b4ff938ab806c29b80069a6cc112a54f5bb78efc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3a8c93ee8ce069cfe77854f7792abadf528addc7e258cd55c84f1b8b2da0b25b278b768e3c8b41a0897a9adc1ee497a9cac4d2ced4c3dd5c644d17f9eb1327c8
|
|
7
|
+
data.tar.gz: 2909206008b7794ce6ec3a7c850acf66c475c33bc543d55defd9222d8f606fb661d31b99443cc4ba7a0d3d1da920d3a85209d0f8e0cca3de1cd611ccc43634ac
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.0.
|
|
1
|
+
4.0.2
|
data/README.md
CHANGED
|
@@ -32,8 +32,8 @@ port = ARGV[0] || 8888
|
|
|
32
32
|
socket = TCPServer.new(port)
|
|
33
33
|
|
|
34
34
|
server = Biryani::Server.new(
|
|
35
|
-
# @param _req [Biryani::
|
|
36
|
-
# @param res [Biryani::
|
|
35
|
+
# @param _req [Biryani::HTTP::Request]
|
|
36
|
+
# @param res [Biryani::HTTP::Response]
|
|
37
37
|
Ractor.shareable_proc do |_req, res|
|
|
38
38
|
res.status = 200
|
|
39
39
|
res.content = 'Hello, world!'
|
data/example/echo.rb
CHANGED
|
@@ -9,8 +9,8 @@ port = ARGV[0] || 8888
|
|
|
9
9
|
socket = TCPServer.new(port)
|
|
10
10
|
|
|
11
11
|
server = Biryani::Server.new(
|
|
12
|
-
# @param req [Biryani::
|
|
13
|
-
# @param res [Biryani::
|
|
12
|
+
# @param req [Biryani::HTTP::Request]
|
|
13
|
+
# @param res [Biryani::HTTP::Response]
|
|
14
14
|
Ractor.shareable_proc do |req, res|
|
|
15
15
|
res.status = 200
|
|
16
16
|
res.content = if req.method.upcase == 'POST'
|
data/example/hello_world.rb
CHANGED
|
@@ -9,8 +9,8 @@ port = ARGV[0] || 8888
|
|
|
9
9
|
socket = TCPServer.new(port)
|
|
10
10
|
|
|
11
11
|
server = Biryani::Server.new(
|
|
12
|
-
# @param _req [Biryani::
|
|
13
|
-
# @param res [Biryani::
|
|
12
|
+
# @param _req [Biryani::HTTP::Request]
|
|
13
|
+
# @param res [Biryani::HTTP::Response]
|
|
14
14
|
Ractor.shareable_proc do |_req, res|
|
|
15
15
|
res.status = 200
|
|
16
16
|
res.content = 'Hello, world!'
|
data/lib/biryani/connection.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Biryani
|
|
|
19
19
|
|
|
20
20
|
# proc [Proc]
|
|
21
21
|
def initialize(proc)
|
|
22
|
-
@sock = nil # Ractor
|
|
22
|
+
@sock = nil # Ractor::Port
|
|
23
23
|
@proc = proc
|
|
24
24
|
@streams_ctx = StreamsContext.new(proc)
|
|
25
25
|
@encoder = HPACK::Encoder.new(4_096)
|
|
@@ -43,10 +43,10 @@ module Biryani
|
|
|
43
43
|
self.class.do_send(io, Frame::Settings.new(false, 0, {}), true)
|
|
44
44
|
|
|
45
45
|
recv_loop(io.clone)
|
|
46
|
-
|
|
46
|
+
select_loop(io)
|
|
47
47
|
rescue StandardError => e
|
|
48
48
|
puts e.backtrace
|
|
49
|
-
self.class.do_send(io, Frame::Goaway.new(
|
|
49
|
+
self.class.do_send(io, Frame::Goaway.new(@streams_ctx.last_stream_id, ErrorCode::INTERNAL_ERROR, 'internal error'), true)
|
|
50
50
|
ensure
|
|
51
51
|
io.close_write
|
|
52
52
|
end
|
|
@@ -64,58 +64,59 @@ module Biryani
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
# @param io [IO]
|
|
67
|
-
|
|
68
|
-
# rubocop: disable Metrics/BlockLength
|
|
69
|
-
# rubocop: disable Metrics/CyclomaticComplexity
|
|
70
|
-
# rubocop: disable Metrics/MethodLength
|
|
71
|
-
# rubocop: disable Metrics/PerceivedComplexity
|
|
72
|
-
def send_loop(io)
|
|
67
|
+
def select_loop(io)
|
|
73
68
|
loop do
|
|
74
69
|
break if @sock.closed? && @streams_ctx.empty?
|
|
75
70
|
|
|
76
71
|
port, obj = Ractor.select(@sock, @streams_ctx.tx)
|
|
77
72
|
if port == @sock
|
|
78
|
-
|
|
79
|
-
reply_frame = Biryani.unwrap(obj, @streams_ctx.last_stream_id)
|
|
80
|
-
self.class.do_send(io, reply_frame, true)
|
|
81
|
-
close if self.class.transition_stream_state_send(reply_frame, @streams_ctx)
|
|
82
|
-
elsif obj.length > @settings[SettingsID::SETTINGS_MAX_FRAME_SIZE]
|
|
83
|
-
self.class.do_send(io, Frame::Goaway.new(0, @streams_ctx.last_stream_id, ErrorCode::FRAME_SIZE_ERROR, 'payload length greater than SETTINGS_MAX_FRAME_SIZE'), true)
|
|
84
|
-
close
|
|
85
|
-
else
|
|
86
|
-
recv_dispatch(obj).each do |frame|
|
|
87
|
-
reply_frame = Biryani.unwrap(frame, @streams_ctx.last_stream_id)
|
|
88
|
-
self.class.do_send(io, reply_frame, true)
|
|
89
|
-
if reply_frame.f_type == FrameType::WINDOW_UPDATE && reply_frame.stream_id.zero?
|
|
90
|
-
@recv_window.increase!(reply_frame.window_size_increment)
|
|
91
|
-
elsif reply_frame.f_type == FrameType::WINDOW_UPDATE
|
|
92
|
-
@streams_ctx[reply_frame.stream_id].recv_window.increase!(reply_frame.window_size_increment)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
close if self.class.transition_stream_state_send(reply_frame, @streams_ctx)
|
|
96
|
-
end
|
|
97
|
-
end
|
|
73
|
+
recv_dispatch(io, obj)
|
|
98
74
|
else
|
|
99
75
|
res, stream_id = obj
|
|
100
|
-
|
|
101
|
-
max_frame_size = @peer_settings[SettingsID::SETTINGS_MAX_FRAME_SIZE]
|
|
102
|
-
self.class.send_headers(io, stream_id, fragment, data.empty?, max_frame_size, @streams_ctx)
|
|
103
|
-
self.class.send_data(io, stream_id, data, @send_window, max_frame_size, @streams_ctx, @data_buffer) unless data.empty?
|
|
76
|
+
handle_response(io, res, stream_id)
|
|
104
77
|
end
|
|
105
78
|
|
|
106
79
|
break if closed?
|
|
107
80
|
end
|
|
108
81
|
end
|
|
82
|
+
|
|
83
|
+
# @param io [IO]
|
|
84
|
+
# @param obj [Object]
|
|
85
|
+
#
|
|
86
|
+
# @return [Array<Object>, Array<ConnectionError>, Array<StreamError>] frames or errors
|
|
87
|
+
# rubocop: disable Metrics/AbcSize
|
|
88
|
+
# rubocop: disable Metrics/CyclomaticComplexity
|
|
89
|
+
# rubocop: disable Metrics/PerceivedComplexity
|
|
90
|
+
def recv_dispatch(io, obj)
|
|
91
|
+
if Biryani.err?(obj)
|
|
92
|
+
reply_frame = Biryani.unwrap(obj, @streams_ctx.last_stream_id)
|
|
93
|
+
self.class.do_send(io, reply_frame, true)
|
|
94
|
+
close if self.class.transition_stream_state_send(reply_frame, @streams_ctx)
|
|
95
|
+
elsif obj.length > @settings[SettingsID::SETTINGS_MAX_FRAME_SIZE]
|
|
96
|
+
self.class.do_send(io, Frame::Goaway.new(@streams_ctx.last_stream_id, ErrorCode::FRAME_SIZE_ERROR, 'payload length greater than SETTINGS_MAX_FRAME_SIZE'), true)
|
|
97
|
+
close
|
|
98
|
+
else
|
|
99
|
+
do_recv_dispatch(obj).each do |frame|
|
|
100
|
+
reply_frame = Biryani.unwrap(frame, @streams_ctx.last_stream_id)
|
|
101
|
+
self.class.do_send(io, reply_frame, true)
|
|
102
|
+
if reply_frame.f_type == FrameType::WINDOW_UPDATE && reply_frame.stream_id.zero?
|
|
103
|
+
@recv_window.increase!(reply_frame.window_size_increment)
|
|
104
|
+
elsif reply_frame.f_type == FrameType::WINDOW_UPDATE
|
|
105
|
+
@streams_ctx[reply_frame.stream_id].recv_window.increase!(reply_frame.window_size_increment)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
close if self.class.transition_stream_state_send(reply_frame, @streams_ctx)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
109
112
|
# rubocop: enable Metrics/AbcSize
|
|
110
|
-
# rubocop: enable Metrics/BlockLength
|
|
111
113
|
# rubocop: enable Metrics/CyclomaticComplexity
|
|
112
|
-
# rubocop: enable Metrics/MethodLength
|
|
113
114
|
# rubocop: enable Metrics/PerceivedComplexity
|
|
114
115
|
|
|
115
116
|
# @param frame [Object]
|
|
116
117
|
#
|
|
117
118
|
# @return [Array<Object>, Array<ConnectionError>, Array<StreamError>] frames or errors
|
|
118
|
-
def
|
|
119
|
+
def do_recv_dispatch(frame)
|
|
119
120
|
receiving_continuation_stream_id = @streams_ctx.receiving_continuation_stream_id
|
|
120
121
|
return [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', typ)} for stream identifier #{format('0x%02x', stream_id)}")] \
|
|
121
122
|
if !receiving_continuation_stream_id.nil? && frame.stream_id != receiving_continuation_stream_id
|
|
@@ -223,6 +224,16 @@ module Biryani
|
|
|
223
224
|
# rubocop: enable Metrics/CyclomaticComplexity
|
|
224
225
|
# rubocop: enable Metrics/MethodLength
|
|
225
226
|
|
|
227
|
+
# @param io [IO]
|
|
228
|
+
# @param res [HTTP::Response]
|
|
229
|
+
# @param stream_id [Integer]
|
|
230
|
+
def handle_response(io, res, stream_id)
|
|
231
|
+
fragment, data = self.class.http_response(res, @encoder)
|
|
232
|
+
max_frame_size = @peer_settings[SettingsID::SETTINGS_MAX_FRAME_SIZE]
|
|
233
|
+
self.class.send_headers(io, stream_id, fragment, data.empty?, max_frame_size, @streams_ctx)
|
|
234
|
+
self.class.send_data(io, stream_id, data, @send_window, max_frame_size, @streams_ctx, @data_buffer) unless data.empty?
|
|
235
|
+
end
|
|
236
|
+
|
|
226
237
|
def close
|
|
227
238
|
@closed = true
|
|
228
239
|
end
|
|
@@ -449,26 +460,26 @@ module Biryani
|
|
|
449
460
|
# @param content [String]
|
|
450
461
|
# @param decoder [Decoder]
|
|
451
462
|
#
|
|
452
|
-
# @return [
|
|
463
|
+
# @return [HTTP::Request, ConnectionError]
|
|
453
464
|
def self.http_request(fragment, content, decoder)
|
|
454
465
|
obj = decoder.decode(fragment)
|
|
455
466
|
return obj if Biryani.err?(obj)
|
|
456
467
|
|
|
457
468
|
fields = obj
|
|
458
|
-
builder =
|
|
469
|
+
builder = HTTP::RequestBuilder.new
|
|
459
470
|
err = builder.fields(fields)
|
|
460
471
|
return err unless err.nil?
|
|
461
472
|
|
|
462
473
|
builder.build(content)
|
|
463
474
|
end
|
|
464
475
|
|
|
465
|
-
# @param res [
|
|
476
|
+
# @param res [HTTP::Response]
|
|
466
477
|
# @param encoder [Encoder]
|
|
467
478
|
#
|
|
468
479
|
# @return [String] fragment
|
|
469
480
|
# @return [String] data
|
|
470
481
|
def self.http_response(res, encoder)
|
|
471
|
-
|
|
482
|
+
HTTP::ResponseParser.new(res).parse(encoder)
|
|
472
483
|
end
|
|
473
484
|
|
|
474
485
|
# @return [Hash<Integer, Integer>]
|
data/lib/biryani/frame/goaway.rb
CHANGED
|
@@ -6,9 +6,9 @@ module Biryani
|
|
|
6
6
|
# @param last_stream_id [Integer]
|
|
7
7
|
# @param error_code [Integer]
|
|
8
8
|
# @param debug [String]
|
|
9
|
-
def initialize(
|
|
9
|
+
def initialize(last_stream_id, error_code, debug)
|
|
10
10
|
@f_type = FrameType::GOAWAY
|
|
11
|
-
@stream_id =
|
|
11
|
+
@stream_id = 0
|
|
12
12
|
@last_stream_id = last_stream_id
|
|
13
13
|
@error_code = error_code
|
|
14
14
|
@debug = debug
|
|
@@ -33,11 +33,13 @@ module Biryani
|
|
|
33
33
|
#
|
|
34
34
|
# @return [Goaway]
|
|
35
35
|
def self.read(s, _flags, stream_id)
|
|
36
|
+
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid frame') unless stream_id.zero?
|
|
37
|
+
|
|
36
38
|
io = IO::Buffer.for(s)
|
|
37
39
|
last_stream_id, error_code = io.get_values(%i[U32 U32], 0)
|
|
38
40
|
debug = io.get_string(8)
|
|
39
41
|
|
|
40
|
-
Goaway.new(
|
|
42
|
+
Goaway.new(last_stream_id, error_code, debug)
|
|
41
43
|
end
|
|
42
44
|
end
|
|
43
45
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module Biryani
|
|
2
|
+
module HTTP
|
|
3
|
+
class Request
|
|
4
|
+
attr_accessor :method, :uri, :fields, :content
|
|
5
|
+
|
|
6
|
+
# @param method [String]
|
|
7
|
+
# @param uri [URI]
|
|
8
|
+
# @param fields [Hash<String, Array<String>>]
|
|
9
|
+
# @param content [String]
|
|
10
|
+
def initialize(method, uri, fields, content)
|
|
11
|
+
@method = method
|
|
12
|
+
@uri = uri
|
|
13
|
+
@fields = fields
|
|
14
|
+
@content = content
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Array<String>, nil]
|
|
18
|
+
def trailers
|
|
19
|
+
# https://datatracker.ietf.org/doc/html/rfc9110#section-6.6.2-4
|
|
20
|
+
keys = (@fields['trailer'] || []).flat_map { |x| x.split(',').map(&:strip) }
|
|
21
|
+
@fields.slice(*keys)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class RequestBuilder
|
|
26
|
+
PSEUDO_HEADER_FIELDS = [':authority', ':method', ':path', ':scheme'].freeze
|
|
27
|
+
Ractor.make_shareable(PSEUDO_HEADER_FIELDS)
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@h = {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param name [String]
|
|
34
|
+
# @param value [String]
|
|
35
|
+
#
|
|
36
|
+
# @return [nil, ConnectioError]
|
|
37
|
+
# rubocop: disable Metrics/AbcSize
|
|
38
|
+
# rubocop: disable Metrics/CyclomaticComplexity
|
|
39
|
+
# rubocop: disable Metrics/PerceivedComplexity
|
|
40
|
+
def field(name, value)
|
|
41
|
+
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'field name has uppercase letter') if name.downcase != name
|
|
42
|
+
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'unknown pseudo-header field name') if name[0] == ':' && !PSEUDO_HEADER_FIELDS.include?(name)
|
|
43
|
+
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'appear pseudo-header fields after regular fields') if name[0] == ':' && @h.any? { |name_, _| name_[0] != ':' }
|
|
44
|
+
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'duplicated pseudo-header fields') if PSEUDO_HEADER_FIELDS.include?(name) && @h.key?(name)
|
|
45
|
+
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid `#{name}` field") if PSEUDO_HEADER_FIELDS.include?(name) && value.empty?
|
|
46
|
+
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'connection-specific field is forbidden') if name == 'connection'
|
|
47
|
+
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, '`TE` field has a value other than `trailers`') if name == 'te' && value != 'trailers'
|
|
48
|
+
|
|
49
|
+
@h[name] = [] unless @h.key?(name)
|
|
50
|
+
@h[name] << value
|
|
51
|
+
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
# rubocop: enable Metrics/AbcSize
|
|
55
|
+
# rubocop: enable Metrics/CyclomaticComplexity
|
|
56
|
+
# rubocop: enable Metrics/PerceivedComplexity
|
|
57
|
+
|
|
58
|
+
# @param arr [Array]
|
|
59
|
+
#
|
|
60
|
+
# @return [nil, ConnectioError]
|
|
61
|
+
def fields(arr)
|
|
62
|
+
arr.each do |name, value|
|
|
63
|
+
err = field(name, value)
|
|
64
|
+
return err unless err.nil?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @param s [String]
|
|
71
|
+
#
|
|
72
|
+
# @return [Request]
|
|
73
|
+
def build(s)
|
|
74
|
+
# `Ractor.send(req, move: true)` moves entries in HPACK::DynamicTable; therefore, a `dup` call is required.
|
|
75
|
+
h = @h.transform_values { |x| x.map(&:dup) }
|
|
76
|
+
self.class.build(h, s)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param h [Hash<String, Array<String>>]
|
|
80
|
+
# @param s [String]
|
|
81
|
+
#
|
|
82
|
+
# @return [Request, ConnectionError]
|
|
83
|
+
def self.build(h, s)
|
|
84
|
+
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'missing pseudo-header fields') unless PSEUDO_HEADER_FIELDS.all? { |x| h.key?(x) }
|
|
85
|
+
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid content-length') if h.key?('content-length') && !s.empty? && s.length != h['content-length'].to_i
|
|
86
|
+
|
|
87
|
+
scheme = h[':scheme'][0]
|
|
88
|
+
domain = h[':authority'][0]
|
|
89
|
+
path = h[':path'][0]
|
|
90
|
+
uri = URI("#{scheme}://#{domain}#{path}")
|
|
91
|
+
method = h[':method'][0]
|
|
92
|
+
h['cookie'] = [h['cookie'].join('; ')] if h.key?('cookie')
|
|
93
|
+
Request.new(method, uri, h, s)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Biryani
|
|
2
|
+
module HTTP
|
|
3
|
+
class Response
|
|
4
|
+
FORBIDDEN_KEY_CHARS = (0x00..0x20).chain([0x3a]).chain(0x41..0x5a).chain(0x7f..0xff).to_a.freeze
|
|
5
|
+
FORBIDDEN_VALUE_CHARS = [0x00, 0x0a, 0x0d].freeze
|
|
6
|
+
|
|
7
|
+
attr_accessor :status, :fields, :content
|
|
8
|
+
|
|
9
|
+
# @param status [Integer]
|
|
10
|
+
# @param fields [Hash]
|
|
11
|
+
# @param content [String, nil]
|
|
12
|
+
def initialize(status, fields, content)
|
|
13
|
+
@status = status
|
|
14
|
+
@fields = fields
|
|
15
|
+
@content = content
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @raise [InvalidHTTPResponseError]
|
|
19
|
+
# rubocop: disable Metrics/CyclomaticComplexity
|
|
20
|
+
def validate
|
|
21
|
+
# https://datatracker.ietf.org/doc/html/rfc9113#section-8.2.1
|
|
22
|
+
raise Error::InvalidHTTPResponseError, 'invalid HTTP status' if @status < 100 || @status >= 600
|
|
23
|
+
raise Error::InvalidHTTPResponseError, 'HTTP field name contains invalid characters' if (@fields.keys.join.downcase.bytes.uniq & FORBIDDEN_KEY_CHARS).any?
|
|
24
|
+
raise Error::InvalidHTTPResponseError, 'HTTP field value contains NUL, LF or CR' if (@fields.values.join.bytes.uniq & FORBIDDEN_VALUE_CHARS).any?
|
|
25
|
+
raise Error::InvalidHTTPResponseError, 'HTTP field value starts/ends with SP or HTAB' if @fields.values.filter { |s| s.start_with?("\t", ' ') || s.end_with?("\t", ' ') }.any?
|
|
26
|
+
end
|
|
27
|
+
# rubocop: enable Metrics/CyclomaticComplexity
|
|
28
|
+
|
|
29
|
+
def self.default
|
|
30
|
+
Response.new(0, {}, nil)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.internal_server_error
|
|
34
|
+
Response.new(500, {}, 'Internal Server Error')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class ResponseParser
|
|
39
|
+
# @param res [Response]
|
|
40
|
+
def initialize(res)
|
|
41
|
+
@res = res
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Array]
|
|
45
|
+
def fields
|
|
46
|
+
fields = [[':status', @res.status.to_s]]
|
|
47
|
+
@res.fields.each do |name, value|
|
|
48
|
+
fields << [name.to_s.downcase, value.to_s]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
fields
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String]
|
|
55
|
+
def content
|
|
56
|
+
@res.content
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param encoder [Encoder]
|
|
60
|
+
#
|
|
61
|
+
# @return [String] fragment
|
|
62
|
+
# @return [String] data
|
|
63
|
+
def parse(encoder)
|
|
64
|
+
[encoder.encode(fields), content]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/biryani/http.rb
ADDED
data/lib/biryani/stream.rb
CHANGED
|
@@ -2,20 +2,20 @@ module Biryani
|
|
|
2
2
|
class Stream
|
|
3
3
|
attr_accessor :rx
|
|
4
4
|
|
|
5
|
-
# @param tx [Port]
|
|
5
|
+
# @param tx [Ractor::Port]
|
|
6
6
|
# @param stream_id [Integer]
|
|
7
7
|
# @param proc [Proc]
|
|
8
8
|
def initialize(tx, stream_id, proc)
|
|
9
9
|
@rx = Ractor.new(tx, stream_id, proc) do |tx, stream_id, proc|
|
|
10
10
|
unless (req = Ractor.recv).nil?
|
|
11
|
-
res =
|
|
11
|
+
res = HTTP::Response.default
|
|
12
12
|
|
|
13
13
|
begin
|
|
14
14
|
proc.call(req, res)
|
|
15
15
|
res.validate
|
|
16
16
|
rescue StandardError => e
|
|
17
17
|
puts e.backtrace
|
|
18
|
-
res =
|
|
18
|
+
res = HTTP::Response.internal_server_error
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
tx.send([res, stream_id], move: true)
|
data/lib/biryani/version.rb
CHANGED
data/lib/biryani.rb
CHANGED
|
@@ -3,11 +3,9 @@ require 'uri'
|
|
|
3
3
|
require_relative 'biryani/connection_error'
|
|
4
4
|
require_relative 'biryani/connection'
|
|
5
5
|
require_relative 'biryani/data_buffer'
|
|
6
|
-
require_relative 'biryani/error'
|
|
7
6
|
require_relative 'biryani/frame'
|
|
8
7
|
require_relative 'biryani/hpack'
|
|
9
|
-
require_relative 'biryani/
|
|
10
|
-
require_relative 'biryani/http_response'
|
|
8
|
+
require_relative 'biryani/http'
|
|
11
9
|
require_relative 'biryani/server'
|
|
12
10
|
require_relative 'biryani/state'
|
|
13
11
|
require_relative 'biryani/stream_error'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: biryani
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- thekuwayama
|
|
@@ -46,7 +46,6 @@ files:
|
|
|
46
46
|
- lib/biryani/connection.rb
|
|
47
47
|
- lib/biryani/connection_error.rb
|
|
48
48
|
- lib/biryani/data_buffer.rb
|
|
49
|
-
- lib/biryani/error.rb
|
|
50
49
|
- lib/biryani/frame.rb
|
|
51
50
|
- lib/biryani/frame/continuation.rb
|
|
52
51
|
- lib/biryani/frame/data.rb
|
|
@@ -70,8 +69,10 @@ files:
|
|
|
70
69
|
- lib/biryani/hpack/integer.rb
|
|
71
70
|
- lib/biryani/hpack/option.rb
|
|
72
71
|
- lib/biryani/hpack/string.rb
|
|
73
|
-
- lib/biryani/
|
|
74
|
-
- lib/biryani/
|
|
72
|
+
- lib/biryani/http.rb
|
|
73
|
+
- lib/biryani/http/error.rb
|
|
74
|
+
- lib/biryani/http/request.rb
|
|
75
|
+
- lib/biryani/http/response.rb
|
|
75
76
|
- lib/biryani/server.rb
|
|
76
77
|
- lib/biryani/state.rb
|
|
77
78
|
- lib/biryani/stream.rb
|
|
@@ -98,7 +99,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
98
99
|
- !ruby/object:Gem::Version
|
|
99
100
|
version: '0'
|
|
100
101
|
requirements: []
|
|
101
|
-
rubygems_version: 4.0.
|
|
102
|
+
rubygems_version: 4.0.8
|
|
102
103
|
specification_version: 4
|
|
103
104
|
summary: An HTTP/2 server implemented using Ruby Ractor
|
|
104
105
|
test_files: []
|
data/lib/biryani/error.rb
DELETED
data/lib/biryani/http_request.rb
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
module Biryani
|
|
2
|
-
class HTTPRequest
|
|
3
|
-
attr_accessor :method, :uri, :fields, :content
|
|
4
|
-
|
|
5
|
-
# @param method [String]
|
|
6
|
-
# @param uri [URI]
|
|
7
|
-
# @param fields [Hash<String, Array<String>>]
|
|
8
|
-
# @param content [String]
|
|
9
|
-
def initialize(method, uri, fields, content)
|
|
10
|
-
@method = method
|
|
11
|
-
@uri = uri
|
|
12
|
-
@fields = fields
|
|
13
|
-
@content = content
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# @return [Array<String>, nil]
|
|
17
|
-
def trailers
|
|
18
|
-
# https://datatracker.ietf.org/doc/html/rfc9110#section-6.6.2-4
|
|
19
|
-
keys = (@fields['trailer'] || []).flat_map { |x| x.split(',').map(&:strip) }
|
|
20
|
-
@fields.slice(*keys)
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
class HTTPRequestBuilder
|
|
25
|
-
PSEUDO_HEADER_FIELDS = [':authority', ':method', ':path', ':scheme'].freeze
|
|
26
|
-
Ractor.make_shareable(PSEUDO_HEADER_FIELDS)
|
|
27
|
-
|
|
28
|
-
def initialize
|
|
29
|
-
@h = {}
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# @param name [String]
|
|
33
|
-
# @param value [String]
|
|
34
|
-
#
|
|
35
|
-
# @return [nil, ConnectioError]
|
|
36
|
-
# rubocop: disable Metrics/AbcSize
|
|
37
|
-
# rubocop: disable Metrics/CyclomaticComplexity
|
|
38
|
-
# rubocop: disable Metrics/PerceivedComplexity
|
|
39
|
-
def field(name, value)
|
|
40
|
-
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'field name has uppercase letter') if name.downcase != name
|
|
41
|
-
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'unknown pseudo-header field name') if name[0] == ':' && !PSEUDO_HEADER_FIELDS.include?(name)
|
|
42
|
-
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'appear pseudo-header fields after regular fields') if name[0] == ':' && @h.any? { |name_, _| name_[0] != ':' }
|
|
43
|
-
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'duplicated pseudo-header fields') if PSEUDO_HEADER_FIELDS.include?(name) && @h.key?(name)
|
|
44
|
-
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid `#{name}` field") if PSEUDO_HEADER_FIELDS.include?(name) && value.empty?
|
|
45
|
-
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'connection-specific field is forbidden') if name == 'connection'
|
|
46
|
-
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, '`TE` field has a value other than `trailers`') if name == 'te' && value != 'trailers'
|
|
47
|
-
|
|
48
|
-
@h[name] = [] unless @h.key?(name)
|
|
49
|
-
@h[name] << value
|
|
50
|
-
|
|
51
|
-
nil
|
|
52
|
-
end
|
|
53
|
-
# rubocop: enable Metrics/AbcSize
|
|
54
|
-
# rubocop: enable Metrics/CyclomaticComplexity
|
|
55
|
-
# rubocop: enable Metrics/PerceivedComplexity
|
|
56
|
-
|
|
57
|
-
# @param arr [Array]
|
|
58
|
-
#
|
|
59
|
-
# @return [nil, ConnectioError]
|
|
60
|
-
def fields(arr)
|
|
61
|
-
arr.each do |name, value|
|
|
62
|
-
err = field(name, value)
|
|
63
|
-
return err unless err.nil?
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
nil
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# @param s [String]
|
|
70
|
-
#
|
|
71
|
-
# @return [HTTPRequest]
|
|
72
|
-
def build(s)
|
|
73
|
-
# `Ractor.send(req, move: true)` moves entries in HPACK::DynamicTable; therefore, a `dup` call is required.
|
|
74
|
-
h = @h.transform_values { |x| x.map(&:dup) }
|
|
75
|
-
self.class.http_request(h, s)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# @param h [Hash<String, Array<String>>]
|
|
79
|
-
# @param s [String]
|
|
80
|
-
#
|
|
81
|
-
# @return [HTTPRequest, ConnectionError]
|
|
82
|
-
def self.http_request(h, s)
|
|
83
|
-
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'missing pseudo-header fields') unless PSEUDO_HEADER_FIELDS.all? { |x| h.key?(x) }
|
|
84
|
-
return ConnectionError.new(ErrorCode::PROTOCOL_ERROR, 'invalid content-length') if h.key?('content-length') && !s.empty? && s.length != h['content-length'].to_i
|
|
85
|
-
|
|
86
|
-
scheme = h[':scheme'][0]
|
|
87
|
-
domain = h[':authority'][0]
|
|
88
|
-
path = h[':path'][0]
|
|
89
|
-
uri = URI("#{scheme}://#{domain}#{path}")
|
|
90
|
-
method = h[':method'][0]
|
|
91
|
-
h['cookie'] = [h['cookie'].join('; ')] if h.key?('cookie')
|
|
92
|
-
HTTPRequest.new(method, uri, h, s)
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
module Biryani
|
|
2
|
-
class HTTPResponse
|
|
3
|
-
FORBIDDEN_KEY_CHARS = (0x00..0x20).chain([0x3a]).chain(0x41..0x5a).chain(0x7f..0xff).to_a.freeze
|
|
4
|
-
FORBIDDEN_VALUE_CHARS = [0x00, 0x0a, 0x0d].freeze
|
|
5
|
-
|
|
6
|
-
attr_accessor :status, :fields, :content
|
|
7
|
-
|
|
8
|
-
# @param status [Integer]
|
|
9
|
-
# @param fields [Hash]
|
|
10
|
-
# @param content [String, nil]
|
|
11
|
-
def initialize(status, fields, content)
|
|
12
|
-
@status = status
|
|
13
|
-
@fields = fields
|
|
14
|
-
@content = content
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# @raise [InvalidHTTPResponseError]
|
|
18
|
-
# rubocop: disable Metrics/CyclomaticComplexity
|
|
19
|
-
def validate
|
|
20
|
-
# https://datatracker.ietf.org/doc/html/rfc9113#section-8.2.1
|
|
21
|
-
raise Error::InvalidHTTPResponseError, 'invalid HTTP status' if @status < 100 || @status >= 600
|
|
22
|
-
raise Error::InvalidHTTPResponseError, 'HTTP field name contains invalid characters' if (@fields.keys.join.downcase.bytes.uniq & FORBIDDEN_KEY_CHARS).any?
|
|
23
|
-
raise Error::InvalidHTTPResponseError, 'HTTP field value contains NUL, LF or CR' if (@fields.values.join.bytes.uniq & FORBIDDEN_VALUE_CHARS).any?
|
|
24
|
-
raise Error::InvalidHTTPResponseError, 'HTTP field value starts/ends with SP or HTAB' if @fields.values.filter { |s| s.start_with?("\t", ' ') || s.end_with?("\t", ' ') }.any?
|
|
25
|
-
end
|
|
26
|
-
# rubocop: enable Metrics/CyclomaticComplexity
|
|
27
|
-
|
|
28
|
-
def self.default
|
|
29
|
-
HTTPResponse.new(0, {}, nil)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def self.internal_server_error
|
|
33
|
-
HTTPResponse.new(500, {}, 'Internal Server Error')
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
class HTTPResponseParser
|
|
38
|
-
# @param res [HTTPResponse]
|
|
39
|
-
def initialize(res)
|
|
40
|
-
@res = res
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# @return [Array]
|
|
44
|
-
def fields
|
|
45
|
-
fields = [[':status', @res.status.to_s]]
|
|
46
|
-
@res.fields.each do |name, value|
|
|
47
|
-
fields << [name.to_s.downcase, value.to_s]
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
fields
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# @return [String]
|
|
54
|
-
def content
|
|
55
|
-
@res.content
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# @param encoder [Encoder]
|
|
59
|
-
#
|
|
60
|
-
# @return [String] fragment
|
|
61
|
-
# @return [String] data
|
|
62
|
-
def parse(encoder)
|
|
63
|
-
[encoder.encode(fields), content]
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|