biryani 0.0.14 → 0.0.15

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: 96b9dc1294ce1840c02e6cf1700b1ebae51409b586dffd25b7df814acdd65034
4
- data.tar.gz: 66d417773c04e090101661650748415566e9f4ca0f91ff95720b2cb7f8668f7b
3
+ metadata.gz: 68d7513a831a0ea2e4b4bf55400d1e1d464a5c16aa7577ba2c6c7937d0a62019
4
+ data.tar.gz: dad97d0529ef59f2e233da2a7042b6eeacbc95f77328ac0172ad45fcc24fd9db
5
5
  SHA512:
6
- metadata.gz: '079d3ab7c12394a9fd340757c5f076cbe937bd1f1a1e60cd24e98f7535526e320189a35f6baa2a5dab3c621b467fec52566e2cfec43a9b994d9f4e5c9dd818cf'
7
- data.tar.gz: 7eed1c453cef71438ee5e625bd7b954c38c8cb4aa417db79c00894bf00e4f1f500a759a16d8c6ea58d3c90f273b5bcbbbefbe3a2de1059367b087095921aaec8
6
+ metadata.gz: eb4574ee23c377e4919f64f8c84d8798e3f52224788555456cac185e923694bc9dec8647a2e495727652aa398ca44ce1bb5dc4ef57b850f066c1621f0714dcde
7
+ data.tar.gz: efed6f174ab3a74b6fb33d110ed53cb6cd56fef824a928723800b78c7629b620ef6ee647138cdd22b3e8094235eb49b484e7a2a6c88c1e4c9d3fedac354768dc
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.2
1
+ 4.0.7
data/Gemfile CHANGED
@@ -1,9 +1,12 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
3
  group :development do
4
+ gem 'ractor-sharing'
4
5
  gem 'rake', '13.3.1'
5
6
  gem 'rspec'
6
7
  gem 'rubocop', '1.82.1'
8
+ gem 'sqlite3'
9
+ gem 'webrick'
7
10
  end
8
11
 
9
12
  gemspec
data/example/README.md ADDED
@@ -0,0 +1,30 @@
1
+ ## Usage
2
+
3
+ The examples run as follows:
4
+
5
+ ```bash
6
+ $ cd /path/to/biryani/example/
7
+
8
+ $ bundle exec ruby hello_world.rb
9
+
10
+ $ curl --http2-prior-knowledge http://localhost:8888
11
+ Hello, world!
12
+ ```
13
+
14
+ Web browsers do NOT support h2c. If you access the examples from a browser, you could use [nginx](https://nginx.org/en/docs/stream/ngx_stream_core_module.html) as a TLS termination proxy.
15
+
16
+ To terminate TLS, `nginx` requires PEM files of certificate and private key. For example, you could generate a locally-trusted certificate, `server.crt` and `server.key`, using [mkcert](https://github.com/FiloSottile/mkcert).
17
+
18
+ ```bash
19
+ $ mkcert -install
20
+
21
+ $ mkcert -cert-file server.crt -key-file server.key localhost 127.0.0.1
22
+ ```
23
+
24
+ Run `nginx` with [nginx.conf](nginx.conf) in this directory.
25
+
26
+ ```bash
27
+ $ nginx -p "$PWD" -c nginx.conf -g 'daemon off; pid /dev/null; error_log stderr;'
28
+ ```
29
+
30
+ Then open https://localhost:4433 in your browser.
data/example/echo.rb CHANGED
@@ -22,5 +22,5 @@ server = Biryani::Server.new(
22
22
  )
23
23
  server.run(socket)
24
24
 
25
- # $ bundle exec ruby example/echo.rb
25
+ # $ bundle exec ruby echo.rb
26
26
  # $ curl -v --http2-prior-knowledge http://localhost:8888 -X POST -d hi
@@ -18,5 +18,5 @@ server = Biryani::Server.new(
18
18
  )
19
19
  server.run(socket)
20
20
 
21
- # $ bundle exec ruby example/hello_world.rb
21
+ # $ bundle exec ruby hello_world.rb
22
22
  # $ curl -v --http2-prior-knowledge http://localhost:8888
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH << "#{__dir__}/../lib"
4
+
5
+ require 'json'
6
+ require 'socket'
7
+ require 'ractor/keylockhash'
8
+ require 'ractor/lockvar'
9
+ require 'biryani'
10
+
11
+ # `Ractor::LockVar` and `Ractor::KeyLockHash` (ko1/ractor-sharing) are shareable, so every stream Ractor uses NEXT_ID and STORE directly.
12
+ #
13
+ # +-- stream Ractor (POST /) --------------+ +-- NEXT_ID ---------------------+
14
+ # | NEXT_ID.increment --------------------+-------->| Ractor::LockVar |
15
+ # | STORE[id] = value --------------------+----+ +--------------------------------+
16
+ # +----------------------------------------+ |
17
+ # | +-- STORE -----------------------+
18
+ # +--->| Ractor::KeyLockHash |
19
+ # | | 1 => "hoge" |
20
+ # +-- stream Ractor (PUT /:id) ------------+ | | 2 => "piyo" |
21
+ # | STORE.update(id) { ... } -------------+----+ | |
22
+ # +----------------------------------------+ +--------------------------------+
23
+ NEXT_ID = Ractor::LockVar.new(0) # plays the role of AUTOINCREMENT in SQL
24
+ STORE = Ractor::KeyLockHash.new
25
+
26
+ module App
27
+ # @param req [Biryani::HTTP::Request]
28
+ # @param res [Biryani::HTTP::Response]
29
+ def self.call(req, res)
30
+ case req.method
31
+ when 'POST'
32
+ do_post(req.content, res)
33
+ when 'GET'
34
+ req.uri.path == '/' ? do_list(res) : do_get(item_id(req), res)
35
+ when 'PUT'
36
+ do_put(item_id(req), req.content, res)
37
+ when 'DELETE'
38
+ do_delete(item_id(req), res)
39
+ else
40
+ respond(res, 405, { error: 'HTTP Method not allowed' })
41
+ end
42
+ end
43
+
44
+ def self.do_post(value, res)
45
+ id = NEXT_ID.increment
46
+ STORE[id] = value
47
+ respond(res, 201, { status: 'created', id: id })
48
+ end
49
+
50
+ def self.do_list(res)
51
+ items = STORE.to_h.compact.sort.map { |id, value| { id: id, value: value } }
52
+ respond(res, 200, items)
53
+ end
54
+
55
+ def self.do_get(id, res)
56
+ value = STORE[id]
57
+ return respond(res, 404, { error: 'not found' }) if value.nil?
58
+
59
+ respond(res, 200, { id: id, value: value })
60
+ end
61
+
62
+ def self.do_put(id, value, res)
63
+ updated = false
64
+ STORE.update(id) do |old|
65
+ next if old.nil?
66
+
67
+ updated = true
68
+ value
69
+ end
70
+ return respond(res, 404, { error: 'not found' }) unless updated
71
+
72
+ respond(res, 200, { status: 'ok' })
73
+ end
74
+
75
+ def self.do_delete(id, res)
76
+ return respond(res, 404, { error: 'not found' }) if STORE.delete(id).nil?
77
+
78
+ respond(res, 200, { status: 'ok' })
79
+ end
80
+
81
+ # @param req [Biryani::HTTP::Request]
82
+ #
83
+ # @return [Integer]
84
+ def self.item_id(req)
85
+ req.uri.path.split('/').reject(&:empty?).first.to_i
86
+ end
87
+
88
+ def self.respond(res, status, body)
89
+ res.status = status
90
+ res.fields['content-type'] = 'application/json'
91
+ res.content = JSON.generate(body)
92
+ end
93
+ end
94
+
95
+ port = ARGV[0] || 8888
96
+ socket = TCPServer.new(port)
97
+
98
+ server = Biryani::Server.new(
99
+ # @param req [Biryani::HTTP::Request]
100
+ # @param res [Biryani::HTTP::Response]
101
+ Ractor.shareable_proc do |req, res|
102
+ App.call(req, res)
103
+ end
104
+ )
105
+ server.run(socket)
106
+
107
+ # $ bundle exec ruby kv_store.rb
108
+ # $ curl -v --http2-prior-knowledge http://localhost:8888 -X POST -d 'hoge'
109
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/
110
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X PUT -d 'piyo'
111
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/1
112
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X DELETE
@@ -0,0 +1,11 @@
1
+ events {}
2
+
3
+ stream {
4
+ server {
5
+ listen 127.0.0.1:4433 ssl;
6
+ ssl_certificate server.crt;
7
+ ssl_certificate_key server.key;
8
+ ssl_alpn h2;
9
+ proxy_pass 127.0.0.1:8888;
10
+ }
11
+ }
@@ -15,5 +15,5 @@ server = Biryani::Server.new(
15
15
  )
16
16
  server.run(socket)
17
17
 
18
- # $ bundle exec ruby example/raise_error.rb
18
+ # $ bundle exec ruby raise_error.rb
19
19
  # $ curl -v --http2-prior-knowledge http://localhost:8888
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH << "#{__dir__}/../lib"
4
+
5
+ require 'json'
6
+ require 'socket'
7
+ require 'sqlite3'
8
+ require 'biryani'
9
+
10
+ # `sqlite3` is not Ractor-safe, so the main Ractor owns the database, and handlers send queries to it through DB_PORT.
11
+ #
12
+ # +-- main Ractor ---------------------+ +-- stream Ractor (1 Ractor / stream) ------------+
13
+ # | | | |
14
+ # | +-- DB -----------------------+ | | +-- handler -------------------------------+ |
15
+ # | | SQLite3::Database | | | | App.call(req, res) | |
16
+ # | +-----------------------------+ | | | DB.execute(sql, *binds) | |
17
+ # | ^ | | | | | |
18
+ # | | sql | | | | sql | |
19
+ # | | | | | v | |
20
+ # | DB_PORT.receive <---------------+-----------+--+---- DB_PORT.send | |
21
+ # | reply.send ---------------------+-----------+--+---> reply.receive | |
22
+ # | | | +------------------------------------------+ |
23
+ # | Ractor.new(server, socket) | | |
24
+ # +----+-------------------------------+ +-------------------------------------------------+
25
+ # | ^
26
+ # v |
27
+ # +-- server Ractor ----------------------+ +-- connection Ractor (1 Ractor / connection) --+--+
28
+ # | server.run(socket) | | Stream.new(tx, stream_id, proc) | |
29
+ # | Ractor.new(socket.accept, @proc) --+------>| Ractor.new(tx, stream_id, proc) -----------+ |
30
+ # +---------------------------------------+ +--------------------------------------------------+
31
+ DB_PORT = Ractor::Port.new
32
+
33
+ module DB
34
+ class Error < StandardError; end
35
+
36
+ # @param sql [String]
37
+ # @param binds [Array<Object>]
38
+ #
39
+ # @raise [DB::Error] if the query fails on the main Ractor
40
+ #
41
+ # @return [Array<Hash>] rows
42
+ # @return [Integer] changes
43
+ def self.execute(sql, *binds)
44
+ reply = Ractor::Port.new
45
+ DB_PORT.send([sql, binds, reply])
46
+ case reply.receive
47
+ in [:ok, rows, changes]
48
+ [rows, changes]
49
+ in [:error, klass, message]
50
+ raise Error, "#{klass}: #{message}"
51
+ end
52
+ end
53
+
54
+ # @param path [String]
55
+ def self.serve(path)
56
+ db = SQLite3::Database.new(path)
57
+ db.results_as_hash = true
58
+ db.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT);')
59
+
60
+ loop do
61
+ sql, binds, reply = DB_PORT.receive
62
+ begin
63
+ rows = db.execute(sql, binds)
64
+ reply.send([:ok, rows, db.changes])
65
+ rescue StandardError => e
66
+ reply.send([:error, e.class.name, e.message])
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ module App
73
+ # @param req [Biryani::HTTP::Request]
74
+ # @param res [Biryani::HTTP::Response]
75
+ def self.call(req, res)
76
+ case req.method
77
+ when 'POST'
78
+ do_post(req, res)
79
+ when 'GET'
80
+ do_get(req, res)
81
+ when 'PUT'
82
+ do_put(req, res)
83
+ when 'DELETE'
84
+ do_delete(req, res)
85
+ else
86
+ respond(res, 405, { error: 'HTTP Method not allowed' })
87
+ end
88
+ end
89
+
90
+ def self.do_post(req, res)
91
+ name = parse_name(req.content)
92
+ return respond(res, 400, { error: 'not found `name`' }) if name.nil?
93
+
94
+ DB.execute('INSERT INTO users (name) VALUES (?);', name)
95
+ respond(res, 201, { status: 'created' })
96
+ end
97
+
98
+ def self.do_get(req, res)
99
+ if req.uri.path == '/'
100
+ users, = DB.execute('SELECT id, name FROM users ORDER BY id;')
101
+ return respond(res, 200, users)
102
+ end
103
+
104
+ users, = DB.execute('SELECT id, name FROM users WHERE id = ?;', user_id(req))
105
+ return respond(res, 404, { error: 'not found' }) if users.empty?
106
+
107
+ respond(res, 200, users.first)
108
+ end
109
+
110
+ def self.do_put(req, res)
111
+ name = parse_name(req.content)
112
+ return respond(res, 400, { error: 'not found `name`' }) if name.nil?
113
+
114
+ _, changes = DB.execute('UPDATE users SET name = ? WHERE id = ?;', name, user_id(req))
115
+ return respond(res, 404, { error: 'not found' }) if changes.zero?
116
+
117
+ respond(res, 200, { status: 'ok' })
118
+ end
119
+
120
+ def self.do_delete(req, res)
121
+ _, changes = DB.execute('DELETE FROM users WHERE id = ?;', user_id(req))
122
+ return respond(res, 404, { error: 'not found' }) if changes.zero?
123
+
124
+ respond(res, 200, { status: 'ok' })
125
+ end
126
+
127
+ # @param content [String]
128
+ #
129
+ # @return [String, nil]
130
+ def self.parse_name(content)
131
+ user = JSON.parse(content)
132
+ user['name']&.to_s if user.is_a?(Hash)
133
+ rescue JSON::ParserError
134
+ nil
135
+ end
136
+
137
+ # @param req [Biryani::HTTP::Request]
138
+ #
139
+ # @return [Integer]
140
+ def self.user_id(req)
141
+ req.uri.path.split('/').reject(&:empty?).first.to_i
142
+ end
143
+
144
+ def self.respond(res, status, body)
145
+ res.status = status
146
+ res.fields['content-type'] = 'application/json'
147
+ res.content = JSON.generate(body)
148
+ end
149
+ end
150
+
151
+ port = ARGV[0] || 8888
152
+ path = ARGV[1] || '/tmp/sqlite3.db'
153
+ socket = TCPServer.new(port)
154
+
155
+ server = Biryani::Server.new(
156
+ # @param req [Biryani::HTTP::Request]
157
+ # @param res [Biryani::HTTP::Response]
158
+ Ractor.shareable_proc do |req, res|
159
+ App.call(req, res)
160
+ end
161
+ )
162
+ Ractor.new(server, socket) { |s, sock| s.run(sock) }
163
+ DB.serve(path)
164
+
165
+ # $ bundle exec ruby sqlite3.rb
166
+ # $ curl -v --http2-prior-knowledge http://localhost:8888 -X POST -H "Content-Type: application/json" -d '{"name":"Alice","age":18}'
167
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/
168
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X PUT -H "Content-Type: application/json" -d '{"name":"Bob","age":20}'
169
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/
170
+ # $ curl -v --http2-prior-knowledge http://localhost:8888/1 -X DELETE
@@ -0,0 +1,47 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>svg tiles</title>
6
+ <style>
7
+ #tiles {
8
+ display: grid;
9
+ grid-template-columns: repeat(15, 32px);
10
+ }
11
+ #tiles img {
12
+ display: block;
13
+ width: 32px;
14
+ height: 32px;
15
+ }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <p>
20
+ <a href="?latency=0">0 ms</a> | <a href="?latency=100">100 ms</a> |
21
+ <a href="?latency=500">500 ms</a>
22
+ </p>
23
+ <p id="result">loading...</p>
24
+ <div id="tiles"></div>
25
+ <script>
26
+ const GRID = 15;
27
+ const latency =
28
+ new URLSearchParams(location.search).get("latency") || "0";
29
+ const tiles = document.getElementById("tiles");
30
+ for (let y = 0; y < GRID; y++) {
31
+ for (let x = 0; x < GRID; x++) {
32
+ const img = document.createElement("img");
33
+ img.src = `/tile/${x}/${y}.svg?latency=${latency}`;
34
+ tiles.appendChild(img);
35
+ }
36
+ }
37
+
38
+ window.addEventListener("load", () => {
39
+ const protocol =
40
+ performance.getEntriesByType("navigation")[0].nextHopProtocol;
41
+ const time = Math.round(performance.now());
42
+ document.getElementById("result").textContent =
43
+ `${protocol}, latency: ${latency} ms / tile, loaded in ${time} ms`;
44
+ });
45
+ </script>
46
+ </body>
47
+ </html>
data/example/tiles.rb ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH << "#{__dir__}/../lib"
4
+
5
+ require 'optparse'
6
+ require 'uri'
7
+
8
+ INDEX_HTML = File.read("#{__dir__}/tiles/index.html").freeze
9
+ TILE_SIZE = 32
10
+
11
+ module App
12
+ def self.call(path, query)
13
+ case path
14
+ when '/', '/index.html'
15
+ [200, 'text/html; charset=utf-8', INDEX_HTML]
16
+ when %r{^/tile/(\d+)/(\d+)\.svg$}
17
+ do_tile(Regexp.last_match(1).to_i, Regexp.last_match(2).to_i, latency(query))
18
+ else
19
+ [404, 'text/plain', 'not found']
20
+ end
21
+ end
22
+
23
+ def self.do_tile(x, y, latency)
24
+ sleep(latency / 1000.0)
25
+ hue = (x + y) * 12 % 360
26
+ svg = <<~SVG
27
+ <svg xmlns="http://www.w3.org/2000/svg" width="#{TILE_SIZE}" height="#{TILE_SIZE}">
28
+ <rect width="#{TILE_SIZE}" height="#{TILE_SIZE}" fill="hsl(#{hue}, 70%, 70%)"/>
29
+ </svg>
30
+ SVG
31
+ [200, 'image/svg+xml', svg]
32
+ end
33
+
34
+ def self.latency(query)
35
+ query = URI.decode_www_form(query || '').to_h
36
+ query['latency'].to_i.clamp(0, 500)
37
+ end
38
+ end
39
+
40
+ http1 = false
41
+ opt = OptionParser.new
42
+ opt.banner = 'Usage: tiles.rb [--http2 | --http1.1] [port]'
43
+ opt.on('--http2', 'serve over HTTP/2 with biryani (default)') { http1 = false }
44
+ opt.on('--http1.1', 'serve over HTTP/1.1 with WEBrick') { http1 = true }
45
+ opt.parse!
46
+ port = ARGV[0] || 8888
47
+
48
+ if http1
49
+ require 'webrick'
50
+
51
+ server = WEBrick::HTTPServer.new(Port: port)
52
+ server.mount_proc('/') do |req, res|
53
+ status, content_type, content = App.call(req.path, req.query_string)
54
+ res.status = status
55
+ res['content-type'] = content_type
56
+ res['cache-control'] = 'no-store'
57
+ res.body = content
58
+ end
59
+ trap('INT') { server.shutdown }
60
+ server.start
61
+ else
62
+ require 'socket'
63
+ require 'biryani'
64
+
65
+ socket = TCPServer.new(port)
66
+ server = Biryani::Server.new(
67
+ # @param req [Biryani::HTTP::Request]
68
+ # @param res [Biryani::HTTP::Response]
69
+ Ractor.shareable_proc do |req, res|
70
+ status, content_type, content = App.call(req.uri.path, req.uri.query)
71
+ res.status = status
72
+ res.fields['content-type'] = content_type
73
+ res.content = content
74
+ res.fields['cache-control'] = 'no-store'
75
+ end
76
+ )
77
+ server.run(socket)
78
+ end
79
+
80
+ # $ bundle exec ruby tiles.rb --http1.1 8889
81
+ # $ open http://localhost:8889/?latency=500
82
+ #
83
+ # $ bundle exec ruby tiles.rb --http2
84
+ # $ nginx -p "$PWD" -c nginx.conf -g 'daemon off; pid /dev/null; error_log stderr;'
85
+ # $ open https://localhost:4433/?latency=500
@@ -148,7 +148,8 @@ module Biryani
148
148
  return [] if obj.nil?
149
149
 
150
150
  settings_ack = obj
151
- [settings_ack]
151
+ max_frame_size = @peer_settings[SettingsID::SETTINGS_MAX_FRAME_SIZE]
152
+ [settings_ack] + @data_buffer.take!(@send_window, @streams_ctx, max_frame_size)
152
153
  when FrameType::PING
153
154
  obj = self.class.handle_ping(frame)
154
155
  return [] if obj.nil?
@@ -185,7 +186,7 @@ module Biryani
185
186
  return [ConnectionError.new(ErrorCode::PROTOCOL_ERROR, "invalid frame type #{format('0x%02x', typ)} for stream identifier #{format('0x%02x', stream_id)}")] \
186
187
  if [FrameType::SETTINGS, FrameType::PING, FrameType::GOAWAY].include?(typ)
187
188
 
188
- max_streams = @peer_settings[SettingsID::SETTINGS_MAX_CONCURRENT_STREAMS]
189
+ max_streams = @settings[SettingsID::SETTINGS_MAX_CONCURRENT_STREAMS]
189
190
  send_initial_window_size = @peer_settings[SettingsID::SETTINGS_INITIAL_WINDOW_SIZE]
190
191
  recv_initial_window_size = @settings[SettingsID::SETTINGS_INITIAL_WINDOW_SIZE]
191
192
  obj = self.class.transition_stream_state_recv(frame, @streams_ctx, @data_buffer, stream_id, max_streams, send_initial_window_size, recv_initial_window_size)
@@ -365,8 +366,9 @@ module Biryani
365
366
  # @param streams_ctx [StreamsContext]
366
367
  # @param decoder [Decoder]
367
368
  #
368
- # @return [Array<WindowUpdate>, ConnectionError]
369
+ # @return [Array<WindowUpdate>, ConnectionError, StreamError]
369
370
  # rubocop: disable Metrics/AbcSize
371
+ # rubocop: disable Metrics/CyclomaticComplexity
370
372
  def self.handle_data(stream_id, data, recv_window, streams_ctx, decoder)
371
373
  ctx = streams_ctx[stream_id]
372
374
  return ConnectionError.new(ErrorCode::FLOW_CONTROL_ERROR, 'DATA Frame length exceeds flow-control window size') \
@@ -374,7 +376,8 @@ module Biryani
374
376
 
375
377
  ctx.content << data
376
378
  if ctx.half_closed_remote?
377
- obj = http_request(ctx.fragment, ctx.content, decoder)
379
+ obj = http_request(stream_id, ctx.fragment, ctx.content, decoder)
380
+ ctx << nil if obj.is_a?(StreamError) # terminate the Stream Ractor waiting for a request
378
381
  return obj if Biryani.err?(obj)
379
382
 
380
383
  ctx << obj
@@ -386,16 +389,18 @@ module Biryani
386
389
  window_updates
387
390
  end
388
391
  # rubocop: enable Metrics/AbcSize
392
+ # rubocop: enable Metrics/CyclomaticComplexity
389
393
 
390
394
  # @param headers [Headers]
391
395
  # @param ctx [StreamContext]
392
396
  # @param decoder [Decoder]
393
397
  #
394
- # @return [nil, ConnectionError]
398
+ # @return [nil, ConnectionError, StreamError]
395
399
  def self.handle_headers(headers, ctx, decoder)
396
400
  ctx.fragment << headers.fragment
397
401
  if ctx.half_closed_remote?
398
- obj = http_request(ctx.fragment, ctx.content, decoder)
402
+ obj = http_request(headers.stream_id, ctx.fragment, ctx.content, decoder)
403
+ ctx << nil if obj.is_a?(StreamError) # terminate the Stream Ractor waiting for a request
399
404
  return obj if Biryani.err?(obj)
400
405
 
401
406
  ctx << obj
@@ -463,21 +468,23 @@ module Biryani
463
468
  nil
464
469
  end
465
470
 
471
+ # @param stream_id [Integer]
466
472
  # @param fragment [String]
467
473
  # @param content [String]
468
474
  # @param decoder [Decoder]
469
475
  #
470
- # @return [HTTP::Request, ConnectionError]
471
- def self.http_request(fragment, content, decoder)
476
+ # @return [HTTP::Request, ConnectionError, StreamError]
477
+ def self.http_request(stream_id, fragment, content, decoder)
472
478
  obj = decoder.decode(fragment)
473
479
  return obj if Biryani.err?(obj)
474
480
 
475
481
  fields = obj
476
482
  builder = HTTP::RequestBuilder.new
477
- err = builder.fields(fields)
478
- return err unless err.nil?
479
-
483
+ builder.fields(fields)
480
484
  builder.build(content)
485
+ rescue HTTP::Error::MalformedRequestError => e
486
+ # https://datatracker.ietf.org/doc/html/rfc9113#section-8.1.1-3
487
+ StreamError.new(ErrorCode::PROTOCOL_ERROR, stream_id, e.message)
481
488
  end
482
489
 
483
490
  # @param res [HTTP::Response]
@@ -4,7 +4,9 @@ module Biryani
4
4
  # Generic error, common for all classes under Biryani::HTTP::Error module.
5
5
  class Error < StandardError; end
6
6
 
7
- class InvalidHTTPResponseError < Error; end
7
+ class InvalidResponseError < Error; end
8
+
9
+ class MalformedRequestError < Error; end
8
10
  end
9
11
  end
10
12
  end
@@ -25,6 +25,8 @@ module Biryani
25
25
  class RequestBuilder
26
26
  PSEUDO_HEADER_FIELDS = [':authority', ':method', ':path', ':scheme'].freeze
27
27
  Ractor.make_shareable(PSEUDO_HEADER_FIELDS)
28
+ REQUIRED_PSEUDO_HEADER_FIELDS = [':method', ':path', ':scheme'].freeze
29
+ Ractor.make_shareable(REQUIRED_PSEUDO_HEADER_FIELDS)
28
30
 
29
31
  def initialize
30
32
  @h = {}
@@ -33,23 +35,21 @@ module Biryani
33
35
  # @param name [String]
34
36
  # @param value [String]
35
37
  #
36
- # @return [nil, ConnectioError]
38
+ # @raise [Error::MalformedRequestError]
37
39
  # rubocop: disable Metrics/AbcSize
38
40
  # rubocop: disable Metrics/CyclomaticComplexity
39
41
  # rubocop: disable Metrics/PerceivedComplexity
40
42
  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'
43
+ raise Error::MalformedRequestError, 'field name has uppercase letter' if name.downcase != name
44
+ raise Error::MalformedRequestError, 'unknown pseudo-header field name' if name[0] == ':' && !PSEUDO_HEADER_FIELDS.include?(name)
45
+ raise Error::MalformedRequestError, 'appear pseudo-header fields after regular fields' if name[0] == ':' && @h.any? { |name_, _| name_[0] != ':' }
46
+ raise Error::MalformedRequestError, 'duplicated pseudo-header fields' if PSEUDO_HEADER_FIELDS.include?(name) && @h.key?(name)
47
+ raise Error::MalformedRequestError, "invalid `#{name}` field" if PSEUDO_HEADER_FIELDS.include?(name) && value.empty?
48
+ raise Error::MalformedRequestError, 'connection-specific field is forbidden' if name == 'connection'
49
+ raise Error::MalformedRequestError, '`TE` field has a value other than `trailers`' if name == 'te' && value != 'trailers'
48
50
 
49
51
  @h[name] = [] unless @h.key?(name)
50
52
  @h[name] << value
51
-
52
- nil
53
53
  end
54
54
  # rubocop: enable Metrics/AbcSize
55
55
  # rubocop: enable Metrics/CyclomaticComplexity
@@ -57,19 +57,18 @@ module Biryani
57
57
 
58
58
  # @param arr [Array]
59
59
  #
60
- # @return [nil, ConnectioError]
60
+ # @raise [Error::MalformedRequestError]
61
61
  def fields(arr)
62
62
  arr.each do |name, value|
63
- err = field(name, value)
64
- return err unless err.nil?
63
+ field(name, value)
65
64
  end
66
-
67
- nil
68
65
  end
69
66
 
70
67
  # @param s [String]
71
68
  #
72
69
  # @return [Request]
70
+ #
71
+ # @raise [Error::MalformedRequestError]
73
72
  def build(s)
74
73
  # `Ractor.send(req, move: true)` moves entries in HPACK::DynamicTable; therefore, a `dup` call is required.
75
74
  h = @h.transform_values { |x| x.map(&:dup) }
@@ -79,19 +78,48 @@ module Biryani
79
78
  # @param h [Hash<String, Array<String>>]
80
79
  # @param s [String]
81
80
  #
82
- # @return [Request, ConnectionError]
81
+ # @return [Request]
82
+ #
83
+ # @raise [Error::MalformedRequestError]
83
84
  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'][0].to_i
85
+ # https://datatracker.ietf.org/doc/html/rfc9113#section-8.3.1-3
86
+ raise Error::MalformedRequestError, 'missing pseudo-header fields' unless REQUIRED_PSEUDO_HEADER_FIELDS.all? { |x| h.key?(x) }
87
+ raise Error::MalformedRequestError, 'invalid content-length' if h.key?('content-length') && !s.empty? && s.length != h['content-length'][0].to_i
86
88
 
89
+ authority = authority(h)
87
90
  scheme = h[':scheme'][0]
88
- domain = h[':authority'][0]
89
91
  path = h[':path'][0]
90
- uri = URI("#{scheme}://#{domain}#{path}")
92
+ uri = URI("#{scheme}://#{authority}#{path}")
91
93
  method = h[':method'][0].upcase
92
94
  h['cookie'] = [h['cookie'].join('; ')] if h.key?('cookie')
93
95
  Request.new(method, uri, h.except(*PSEUDO_HEADER_FIELDS), s)
94
96
  end
97
+
98
+ # @param h [Hash<String, Array<String>>]
99
+ #
100
+ # @return [String]
101
+ #
102
+ # @raise [Error::MalformedRequestError]
103
+ # rubocop: disable Metrics/CyclomaticComplexity
104
+ # rubocop: disable Metrics/PerceivedComplexity
105
+ def self.authority(h)
106
+ host = h['host']
107
+ # https://datatracker.ietf.org/doc/html/rfc9110#section-5.3-3
108
+ raise Error::MalformedRequestError, 'duplicated host fields' if !host.nil? && host.length > 1
109
+
110
+ authority = h[':authority']&.first
111
+ # https://datatracker.ietf.org/doc/html/rfc9113#section-8.3.1-2.3.3
112
+ raise Error::MalformedRequestError, 'mismatched :authority and host fields' if !authority.nil? && !host.nil? && authority.downcase != host[0].downcase
113
+
114
+ s = authority || host&.first
115
+ # https://datatracker.ietf.org/doc/html/rfc9110#section-4.2.1-4
116
+ # https://datatracker.ietf.org/doc/html/rfc9110#section-4.2.2-4
117
+ raise Error::MalformedRequestError, 'missing :authority and host fields' if s.nil? || s.empty?
118
+
119
+ s
120
+ end
121
+ # rubocop: enable Metrics/CyclomaticComplexity
122
+ # rubocop: enable Metrics/PerceivedComplexity
95
123
  end
96
124
  end
97
125
  end
@@ -15,14 +15,14 @@ module Biryani
15
15
  @content = content
16
16
  end
17
17
 
18
- # @raise [InvalidHTTPResponseError]
18
+ # @raise [InvalidResponseError]
19
19
  # rubocop: disable Metrics/CyclomaticComplexity
20
20
  def validate
21
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?
22
+ raise Error::InvalidResponseError, 'invalid HTTP status' if @status < 100 || @status >= 600
23
+ raise Error::InvalidResponseError, 'HTTP field name contains invalid characters' if (@fields.keys.join.downcase.bytes.uniq & FORBIDDEN_KEY_CHARS).any?
24
+ raise Error::InvalidResponseError, 'HTTP field value contains NUL, LF or CR' if (@fields.values.join.bytes.uniq & FORBIDDEN_VALUE_CHARS).any?
25
+ raise Error::InvalidResponseError, 'HTTP field value starts/ends with SP or HTAB' if @fields.values.filter { |s| s.start_with?("\t", ' ') || s.end_with?("\t", ' ') }.any?
26
26
  end
27
27
  # rubocop: enable Metrics/CyclomaticComplexity
28
28
 
@@ -1,3 +1,3 @@
1
1
  module Biryani
2
- VERSION = '0.0.14'.freeze
2
+ VERSION = '0.0.15'.freeze
3
3
  end
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.14
4
+ version: 0.0.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - thekuwayama
@@ -39,9 +39,15 @@ files:
39
39
  - biryani.gemspec
40
40
  - conformance/server_spec.rb
41
41
  - conformance/spec_helper.rb
42
+ - example/README.md
42
43
  - example/echo.rb
43
44
  - example/hello_world.rb
45
+ - example/kv_store.rb
46
+ - example/nginx.conf
44
47
  - example/raise_error.rb
48
+ - example/sqlite3.rb
49
+ - example/tiles.rb
50
+ - example/tiles/index.html
45
51
  - lib/biryani.rb
46
52
  - lib/biryani/connection.rb
47
53
  - lib/biryani/connection_error.rb
@@ -99,7 +105,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
99
105
  - !ruby/object:Gem::Version
100
106
  version: '0'
101
107
  requirements: []
102
- rubygems_version: 4.0.6
108
+ rubygems_version: 4.0.20
103
109
  specification_version: 4
104
110
  summary: An HTTP/2 server implemented using Ruby Ractor
105
111
  test_files: []