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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33f21fe89774fbe47cfaa0f36bb1eaaabc333701ffce568c410818aa86c6441d
4
- data.tar.gz: 290247cccbfbf21eb1d6d4e93b1f39a4cfb5dbe26fcef278f9804f95221e7ec1
3
+ metadata.gz: 1aa3fb4dd49abcba22df142cd67d38c5eedc6c8beb6ca8fbcb130147c7e7506a
4
+ data.tar.gz: 54bd5a493c027047eeaada80b4ff938ab806c29b80069a6cc112a54f5bb78efc
5
5
  SHA512:
6
- metadata.gz: eed8eb6993bdc77026e12b52836a0970bc6ed6ba024f05bb278049e38ddf9528dadea31c8d42660f0d0c38da08295ac7343702436512d0de148f4f6d82205811
7
- data.tar.gz: abffb2767adcddc3556a22367a7a67c78cbc636de1dfa794b65f421c5ac15c30a546e3641edf607bce8119c3c2420856700bbc19439883fc0a74b4fcb3623bc6
6
+ metadata.gz: 3a8c93ee8ce069cfe77854f7792abadf528addc7e258cd55c84f1b8b2da0b25b278b768e3c8b41a0897a9adc1ee497a9cac4d2ced4c3dd5c644d17f9eb1327c8
7
+ data.tar.gz: 2909206008b7794ce6ec3a7c850acf66c475c33bc543d55defd9222d8f606fb661d31b99443cc4ba7a0d3d1da920d3a85209d0f8e0cca3de1cd611ccc43634ac
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.1
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::HTTPRequest]
36
- # @param res [Biryani::HTTPResponse]
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::HTTPRequest]
13
- # @param res [Biryani::HTTPResponse]
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'
@@ -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::HTTPRequest]
13
- # @param res [Biryani::HTTPResponse]
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!'
@@ -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
- send_loop(io)
46
+ select_loop(io)
47
47
  rescue StandardError => e
48
48
  puts e.backtrace
49
- self.class.do_send(io, Frame::Goaway.new(0, @streams_ctx.last_stream_id, ErrorCode::INTERNAL_ERROR, 'internal error'), true)
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
- # rubocop: disable Metrics/AbcSize
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
- if Biryani.err?(obj)
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
- fragment, data = self.class.http_response(res, @encoder)
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 recv_dispatch(frame)
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 [HTTPRequest, ConnectionError]
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 = HTTPRequestBuilder.new
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 [HTTPResponse]
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
- HTTPResponseParser.new(res).parse(encoder)
482
+ HTTP::ResponseParser.new(res).parse(encoder)
472
483
  end
473
484
 
474
485
  # @return [Hash<Integer, Integer>]
@@ -11,7 +11,7 @@ module Biryani
11
11
  #
12
12
  # @return [Goaway]
13
13
  def goaway(last_stream_id)
14
- Frame::Goaway.new(0, last_stream_id, @code, @debug)
14
+ Frame::Goaway.new(last_stream_id, @code, @debug)
15
15
  end
16
16
  end
17
17
  end
@@ -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(stream_id, last_stream_id, error_code, debug)
9
+ def initialize(last_stream_id, error_code, debug)
10
10
  @f_type = FrameType::GOAWAY
11
- @stream_id = 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(stream_id, last_stream_id, error_code, debug)
42
+ Goaway.new(last_stream_id, error_code, debug)
41
43
  end
42
44
  end
43
45
  end
@@ -0,0 +1,10 @@
1
+ module Biryani
2
+ module HTTP
3
+ module Error
4
+ # Generic error, common for all classes under Biryani::HTTP::Error module.
5
+ class Error < StandardError; end
6
+
7
+ class InvalidHTTPResponseError < Error; end
8
+ end
9
+ end
10
+ 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
@@ -0,0 +1,3 @@
1
+ require_relative 'http/error'
2
+ require_relative 'http/request'
3
+ require_relative 'http/response'
@@ -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 = HTTPResponse.default
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 = HTTPResponse.internal_server_error
18
+ res = HTTP::Response.internal_server_error
19
19
  end
20
20
 
21
21
  tx.send([res, stream_id], move: true)
@@ -117,7 +117,7 @@ module Biryani
117
117
  @state = State.new
118
118
  end
119
119
 
120
- # @param req [HTTPRequest]
120
+ # @param req [HTTP::Request]
121
121
  def <<(req)
122
122
  @stream.rx.send(req, move: true)
123
123
  end
@@ -1,3 +1,3 @@
1
1
  module Biryani
2
- VERSION = '0.0.9'.freeze
2
+ VERSION = '0.0.10'.freeze
3
3
  end
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/http_request'
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.9
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/http_request.rb
74
- - lib/biryani/http_response.rb
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.6
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
@@ -1,8 +0,0 @@
1
- module Biryani
2
- module Error
3
- # Generic error, common for all classes under Biryani::Error module.
4
- class Error < StandardError; end
5
-
6
- class InvalidHTTPResponseError < Error; end
7
- end
8
- end
@@ -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