syntropy 0.30.0 → 0.32.0
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/.gitignore +2 -0
- data/CHANGELOG.md +30 -0
- data/TODO.md +46 -1
- data/bin/syntropy +8 -86
- data/cmd/_banner.rb +16 -0
- data/cmd/console.rb +77 -0
- data/cmd/help.rb +12 -0
- data/cmd/serve.rb +95 -0
- data/cmd/test.rb +40 -0
- data/examples/{counter.rb → basic/counter.rb} +1 -1
- data/examples/{templates.rb → basic/templates.rb} +1 -1
- data/examples/blog/app/_layout/default.rb +11 -0
- data/examples/blog/app/_lib/post_store.rb +47 -0
- data/examples/blog/app/_schema/2026-01-01-initial.rb +9 -0
- data/examples/blog/app/_setup.rb +4 -0
- data/examples/blog/app/index.rb +7 -0
- data/examples/blog/app/posts/[id]/edit.rb +33 -0
- data/examples/blog/app/posts/[id]/index.rb +58 -0
- data/examples/blog/app/posts/index.rb +38 -0
- data/examples/blog/app/posts/new.rb +29 -0
- data/examples/mcp-oauth/.ruby-version +1 -0
- data/examples/mcp-oauth/Gemfile +8 -0
- data/examples/mcp-oauth/README.md +128 -0
- data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
- data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
- data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
- data/examples/mcp-oauth/app/index.md +1 -0
- data/examples/mcp-oauth/app/mcp.rb +85 -0
- data/examples/mcp-oauth/app/oauth/authorize.rb +18 -0
- data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
- data/examples/mcp-oauth/app/oauth/register.rb +14 -0
- data/examples/mcp-oauth/app/oauth/token.rb +79 -0
- data/examples/mcp-oauth/app/signin.rb +85 -0
- data/examples/mcp-oauth/test/helper.rb +9 -0
- data/examples/mcp-oauth/test/test_app.rb +27 -0
- data/examples/mcp-oauth/test/test_oauth.rb +628 -0
- data/lib/syntropy/app.rb +34 -9
- data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
- data/lib/syntropy/applets/builtin/req.rb +1 -1
- data/lib/syntropy/db/connection_pool.rb +71 -0
- data/lib/syntropy/db/schema.rb +92 -0
- data/lib/syntropy/db/store.rb +31 -0
- data/lib/syntropy/dev_mode.rb +1 -1
- data/lib/syntropy/errors.rb +6 -0
- data/lib/syntropy/http/client.rb +43 -0
- data/lib/syntropy/http/client_connection.rb +36 -0
- data/lib/syntropy/http/io_extensions.rb +176 -0
- data/lib/syntropy/http/server.rb +5 -5
- data/lib/syntropy/http/{connection.rb → server_connection.rb} +15 -91
- data/lib/syntropy/http.rb +3 -1
- data/lib/syntropy/logger.rb +5 -1
- data/lib/syntropy/{module.rb → module_loader.rb} +47 -8
- data/lib/syntropy/papercraft_extensions.rb +1 -1
- data/lib/syntropy/request/mock_adapter.rb +2 -0
- data/lib/syntropy/request/request_info.rb +22 -4
- data/lib/syntropy/request/response.rb +2 -2
- data/lib/syntropy/request/validation.rb +11 -5
- data/lib/syntropy/routing_tree.rb +2 -1
- data/lib/syntropy/test.rb +77 -0
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +5 -23
- data/syntropy.gemspec +3 -3
- data/test/app/.well-known/foo.rb +3 -0
- data/test/app/_hook.rb +1 -1
- data/test/app/by_method.rb +9 -0
- data/test/app_setup/_setup.rb +7 -0
- data/test/app_setup/index.rb +1 -0
- data/test/app_with_schema/_schema/2026-01-02-foo.rb +12 -0
- data/test/app_with_schema/_schema/2026-05-30-bar.rb +7 -0
- data/test/helper.rb +1 -25
- data/test/schema/2026-01-02-foo.rb +12 -0
- data/test/schema/2026-05-30-bar.rb +7 -0
- data/test/test_app.rb +110 -70
- data/test/test_caching.rb +1 -1
- data/test/{test_connection_pool.rb → test_db_connection_pool.rb} +7 -2
- data/test/test_db_schema.rb +96 -0
- data/test/test_db_store.rb +24 -0
- data/test/test_http_client.rb +52 -0
- data/test/test_http_client_connection.rb +43 -0
- data/test/test_http_protocol.rb +250 -0
- data/test/{test_connection.rb → test_http_server_connection.rb} +39 -48
- data/test/test_json_api.rb +5 -5
- data/test/{test_module.rb → test_module_loader.rb} +31 -0
- data/test/{test_request_extensions.rb → test_request.rb} +153 -18
- data/test/test_routing_tree.rb +15 -3
- data/test/test_server.rb +9 -13
- metadata +84 -36
- data/lib/syntropy/connection_pool.rb +0 -61
- data/test/test_request_info.rb +0 -90
- /data/examples/{bad.rb → basic/bad.rb} +0 -0
- /data/examples/{card.rb → basic/card.rb} +0 -0
- /data/examples/{counter.js → basic/counter.js} +0 -0
- /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
- /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
- /data/examples/{index.md → basic/index.md} +0 -0
data/lib/syntropy/app.rb
CHANGED
|
@@ -6,7 +6,7 @@ require 'yaml'
|
|
|
6
6
|
require 'papercraft'
|
|
7
7
|
|
|
8
8
|
require 'syntropy/errors'
|
|
9
|
-
require 'syntropy/
|
|
9
|
+
require 'syntropy/module_loader'
|
|
10
10
|
require 'syntropy/routing_tree'
|
|
11
11
|
require 'syntropy/mime_types'
|
|
12
12
|
|
|
@@ -35,6 +35,7 @@ module Syntropy
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
attr_reader :module_loader, :routing_tree, :root_dir, :mount_path, :env
|
|
38
|
+
attr_accessor :raise_on_internal_server_error
|
|
38
39
|
|
|
39
40
|
def initialize(**env)
|
|
40
41
|
@machine = env[:machine]
|
|
@@ -101,6 +102,21 @@ module Syntropy
|
|
|
101
102
|
route
|
|
102
103
|
end
|
|
103
104
|
|
|
105
|
+
def setup_db(db_path:, schema_root: '_schema')
|
|
106
|
+
@env[:db_path] = db_path
|
|
107
|
+
@env[:schema_root] = schema_root
|
|
108
|
+
|
|
109
|
+
class << self
|
|
110
|
+
def connection_pool
|
|
111
|
+
@connection_pool ||= DB::ConnectionPool.new(@machine, @env[:db_path], 4)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def schema
|
|
115
|
+
@schema ||= DB::Schema.new(module_loader: @module_loader, schema_root: @env[:schema_root])
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
104
120
|
private
|
|
105
121
|
|
|
106
122
|
# Handles a not found error, taking into account hooks up the tree from the
|
|
@@ -321,7 +337,7 @@ module Syntropy
|
|
|
321
337
|
}
|
|
322
338
|
body {
|
|
323
339
|
markdown md
|
|
324
|
-
|
|
340
|
+
auto_refresh! if @env[:dev_mode]
|
|
325
341
|
}
|
|
326
342
|
}
|
|
327
343
|
}
|
|
@@ -450,16 +466,28 @@ module Syntropy
|
|
|
450
466
|
end
|
|
451
467
|
|
|
452
468
|
RAW_DEFAULT_ERROR_HANDLER = ->(req, err) {
|
|
469
|
+
status = Syntropy::Error.http_status(err)
|
|
470
|
+
|
|
453
471
|
msg = err.message
|
|
454
472
|
msg = nil if msg.empty? || (req.method == 'head')
|
|
455
|
-
req.respond(msg, ':status' =>
|
|
473
|
+
req.respond(msg, ':status' => status) rescue nil
|
|
456
474
|
}
|
|
457
475
|
|
|
458
|
-
|
|
476
|
+
RAISE_INTERNAL_SERVER_ERROR_DEFAULT_ERROR_HANDLER = ->(req, err) {
|
|
477
|
+
status = Syntropy::Error.http_status(err)
|
|
478
|
+
raise if status == HTTP::INTERNAL_SERVER_ERROR
|
|
459
479
|
|
|
480
|
+
msg = err.message
|
|
481
|
+
msg = nil if msg.empty? || (req.method == 'head')
|
|
482
|
+
req.respond(msg, ':status' => status) rescue nil
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
def default_error_handler
|
|
460
486
|
@default_error_handler ||= begin
|
|
461
487
|
if @builtin_applet
|
|
462
488
|
@builtin_applet.module_loader.load('/default_error_handler')
|
|
489
|
+
elsif @raise_on_internal_server_error
|
|
490
|
+
RAISE_INTERNAL_SERVER_ERROR_DEFAULT_ERROR_HANDLER
|
|
463
491
|
else
|
|
464
492
|
RAW_DEFAULT_ERROR_HANDLER
|
|
465
493
|
end
|
|
@@ -471,6 +499,8 @@ module Syntropy
|
|
|
471
499
|
#
|
|
472
500
|
# @return [void]
|
|
473
501
|
def start
|
|
502
|
+
@module_loader.load('_setup', raise_on_missing: false)
|
|
503
|
+
|
|
474
504
|
@machine.spin do
|
|
475
505
|
# we do startup stuff asynchronously, in order to first let Syntropy do
|
|
476
506
|
# its setup tasks.
|
|
@@ -489,9 +519,6 @@ module Syntropy
|
|
|
489
519
|
#
|
|
490
520
|
# @return [void]
|
|
491
521
|
def file_watcher_loop
|
|
492
|
-
wf = @env[:watch_files]
|
|
493
|
-
period = wf.is_a?(Numeric) ? wf : 0.1
|
|
494
|
-
|
|
495
522
|
@machine.file_watch(@root_dir, UM::IN_CREATE | UM::IN_DELETE | UM::IN_CLOSE_WRITE) { |e|
|
|
496
523
|
fn = e[:fn]
|
|
497
524
|
@logger&.info(message: 'File change detected', fn: fn)
|
|
@@ -499,8 +526,6 @@ module Syntropy
|
|
|
499
526
|
debounce_file_change
|
|
500
527
|
}
|
|
501
528
|
|
|
502
|
-
|
|
503
|
-
|
|
504
529
|
# Syntropy.file_watch(@machine, @root_dir, period: period) do |event, fn|
|
|
505
530
|
# @logger&.info(message: 'File change detected', fn: fn)
|
|
506
531
|
# @module_loader.invalidate_fn(fn)
|
|
@@ -23,7 +23,7 @@ ErrorPage = ->(error:, status:, backtrace:) {
|
|
|
23
23
|
}
|
|
24
24
|
end
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
auto_refresh!
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
}
|
|
@@ -32,7 +32,7 @@ def transform_backtrace(backtrace)
|
|
|
32
32
|
backtrace.map do
|
|
33
33
|
if (m = it.match(/^(.+:\d+):/))
|
|
34
34
|
location = m[1]
|
|
35
|
-
{ entry: it, url: "
|
|
35
|
+
{ entry: it, url: "zed://file/#{location}" }
|
|
36
36
|
else
|
|
37
37
|
{ entry: it, url: nil }
|
|
38
38
|
end
|
|
@@ -43,7 +43,7 @@ def error_response_html(req, error)
|
|
|
43
43
|
status = Syntropy::Error.http_status(error)
|
|
44
44
|
backtrace = transform_backtrace(error.backtrace)
|
|
45
45
|
html = Papercraft.html(ErrorPage, error:, status:, backtrace:)
|
|
46
|
-
req.
|
|
46
|
+
req.respond_html(html, ':status' => status)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def error_response_raw(req, error)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'extralite'
|
|
4
|
+
|
|
5
|
+
module Syntropy
|
|
6
|
+
module DB
|
|
7
|
+
class ConnectionPool
|
|
8
|
+
attr_reader :count
|
|
9
|
+
|
|
10
|
+
def initialize(machine, fn, max_conn)
|
|
11
|
+
@machine = machine
|
|
12
|
+
@fn = fn
|
|
13
|
+
@count = 0
|
|
14
|
+
@max_conn = max_conn
|
|
15
|
+
@queue = UM::Queue.new
|
|
16
|
+
@key = :"connection_pool_#{object_id}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def with_db
|
|
20
|
+
if (db = Thread.current[@key])
|
|
21
|
+
@machine.snooze
|
|
22
|
+
return yield(db)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
db = checkout
|
|
26
|
+
begin
|
|
27
|
+
Thread.current[@key] = db
|
|
28
|
+
yield(db)
|
|
29
|
+
ensure
|
|
30
|
+
Thread.current[@key] = nil
|
|
31
|
+
checkin(db)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def query(sql, *, **, &)
|
|
36
|
+
with_db { it.query(sql, *, **, &) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def execute(sql, *, **)
|
|
40
|
+
with_db { it.execute(sql, *, **) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def close
|
|
44
|
+
while @queue.count > 0
|
|
45
|
+
db = @machine.shift(@queue)
|
|
46
|
+
db.close
|
|
47
|
+
@count -= 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def checkout
|
|
54
|
+
return make_db_instance if @queue.count == 0 && @count < @max_conn
|
|
55
|
+
|
|
56
|
+
@machine.shift(@queue)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def checkin(db)
|
|
60
|
+
@machine.push(@queue, db)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def make_db_instance
|
|
64
|
+
Extralite::Database.new(@fn, wal: true).tap do
|
|
65
|
+
@count += 1
|
|
66
|
+
it.on_progress(mode: :at_least_once, period: 320, tick: 10) { @machine.snooze }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syntropy
|
|
4
|
+
module DB
|
|
5
|
+
class Schema
|
|
6
|
+
def initialize(module_loader: nil, schema_root: '_schema', &)
|
|
7
|
+
@migrations = {}
|
|
8
|
+
@module_loader = module_loader
|
|
9
|
+
@schema_root = schema_root
|
|
10
|
+
load_schema_from_modules if @module_loader
|
|
11
|
+
run_schema_block(&) if block_given?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def apply(connection_pool)
|
|
15
|
+
execute_migrations(connection_pool)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def current_version(connection_pool)
|
|
19
|
+
connection_pool.with_db do |db|
|
|
20
|
+
get_schema_version(db)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def load_schema_from_modules
|
|
27
|
+
modules = @module_loader.list(@schema_root)
|
|
28
|
+
modules.each do |name|
|
|
29
|
+
@migrations[File.basename(name)] = @module_loader.load(name)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class SchemaBlockRunner
|
|
34
|
+
def initialize(migrations, &)
|
|
35
|
+
@migrations = migrations
|
|
36
|
+
instance_eval(&)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initial(&block)
|
|
40
|
+
@migrations['0000'] = block
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def version(key, &block)
|
|
44
|
+
@migrations[key] = block
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run_schema_block(&)
|
|
49
|
+
SchemaBlockRunner.new(@migrations, &)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def execute_migrations(connection_pool)
|
|
53
|
+
connection_pool.with_db do |db|
|
|
54
|
+
current_version = get_schema_version(db)
|
|
55
|
+
migrations_keys = @migrations.keys.sort
|
|
56
|
+
migrations_keys.select { it > current_version } if current_version
|
|
57
|
+
|
|
58
|
+
migrations_keys.each do |key|
|
|
59
|
+
db.transaction do
|
|
60
|
+
@migrations[key].(db)
|
|
61
|
+
set_schema_version(db, key)
|
|
62
|
+
current_version = key
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
current_version
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def get_schema_version(db)
|
|
71
|
+
db.execute <<~SQL
|
|
72
|
+
create table if not exists __syntropy_schema__(
|
|
73
|
+
k text primary key,
|
|
74
|
+
v text
|
|
75
|
+
);
|
|
76
|
+
SQL
|
|
77
|
+
db.query_single_splat <<~SQL
|
|
78
|
+
select v from __syntropy_schema__
|
|
79
|
+
where k = 'version'
|
|
80
|
+
SQL
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def set_schema_version(db, version)
|
|
84
|
+
db.execute <<~SQL, v: version
|
|
85
|
+
insert into __syntropy_schema__ (k, v)
|
|
86
|
+
values ('version', :v)
|
|
87
|
+
on conflict(k) do update set v = :v
|
|
88
|
+
SQL
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syntropy
|
|
4
|
+
module DB
|
|
5
|
+
class Store
|
|
6
|
+
def initialize(connection_pool)
|
|
7
|
+
@connection_pool = connection_pool
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def query(sql, *, **)
|
|
11
|
+
@connection_pool.with_db { it.query(sql, *, **) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def query_single_row(sql, *, **)
|
|
15
|
+
@connection_pool.with_db { it.query_single(sql, *, **) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def query_single_value(sql, *, **)
|
|
19
|
+
@connection_pool.with_db { it.query_single_splat(sql, *, **) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def execute(sql, *, **)
|
|
23
|
+
@connection_pool.with_db { it.execute(sql, *, **) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def transaction(&)
|
|
27
|
+
@connection_pool.with_db(&)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/syntropy/dev_mode.rb
CHANGED
data/lib/syntropy/errors.rb
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'syntropy/http/client_connection'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module Syntropy
|
|
7
|
+
module HTTP
|
|
8
|
+
class Client
|
|
9
|
+
def initialize(machine)
|
|
10
|
+
@machine = machine
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def get(url, **headers, &)
|
|
14
|
+
uri = URI.parse(url)
|
|
15
|
+
headers = headers.merge(
|
|
16
|
+
':method' => 'GET',
|
|
17
|
+
':path' => uri.request_uri
|
|
18
|
+
)
|
|
19
|
+
req(uri, **headers, &)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# @param uri [URI]
|
|
25
|
+
def req(uri, **headers)
|
|
26
|
+
connection = make_connection(uri.scheme, uri.host, uri.port)
|
|
27
|
+
response_headers = connection.req(**headers)
|
|
28
|
+
if block_given?
|
|
29
|
+
yield(response_headers, connection)
|
|
30
|
+
else
|
|
31
|
+
[response_headers, connection.get_response_body(response_headers)]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def make_connection(_scheme, host, port)
|
|
36
|
+
ip = (host =~ /^\d+\.\d+\.\d+\.\d+$/) ? host : @machine.resolve(host)[0]
|
|
37
|
+
|
|
38
|
+
fd = @machine.tcp_connect(ip, port)
|
|
39
|
+
Syntropy::HTTP::ClientConnection.new(@machine, fd)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'syntropy/errors'
|
|
4
|
+
require 'syntropy/http/io_extensions'
|
|
5
|
+
|
|
6
|
+
module Syntropy
|
|
7
|
+
module HTTP
|
|
8
|
+
class ClientConnection
|
|
9
|
+
attr_reader :fd, :response_headers, :logger
|
|
10
|
+
|
|
11
|
+
def initialize(machine, fd, io_mode: :socket)
|
|
12
|
+
@machine = machine
|
|
13
|
+
@fd = fd
|
|
14
|
+
@io = machine.io(fd, io_mode)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def req(body: nil, **headers)
|
|
18
|
+
if body
|
|
19
|
+
headers = headers.merge(
|
|
20
|
+
'Content-Length' => body.bytesize
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
@io.http_write_request_headers(**headers)
|
|
24
|
+
if body
|
|
25
|
+
@io.write(body)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@io.http_read_response_headers
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get_response_body(headers)
|
|
32
|
+
@io.http_read_body(headers)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'syntropy/errors'
|
|
4
|
+
|
|
5
|
+
module Syntropy
|
|
6
|
+
module HTTP
|
|
7
|
+
module ProtocolMethods
|
|
8
|
+
RE_REQUEST_LINE = /^(get|head|options|trace|put|delete|post|patch|connect)\s+([^\s]+)\s+HTTP\/([019\.]{1,3})/i
|
|
9
|
+
RE_RESPONSE_LINE = /^HTTP\/1\.1\s+(\d{3})(\s+.+)?$/i
|
|
10
|
+
RE_HEADER_LINE = /^([a-z0-9\-]+):\s+(.+)/i
|
|
11
|
+
|
|
12
|
+
MAX_REQUEST_LINE_LEN = 1 << 14 # 16KB
|
|
13
|
+
MAX_RESPONSE_LINE_LEN = 1 << 8 # 256
|
|
14
|
+
MAX_HEADER_LINE_LEN = 1 << 13 # 8KB
|
|
15
|
+
MAX_CHUNK_SIZE_LEN = 16
|
|
16
|
+
|
|
17
|
+
# @return [Hash] headers
|
|
18
|
+
def http_read_request_headers
|
|
19
|
+
line = read_line(MAX_REQUEST_LINE_LEN)
|
|
20
|
+
return nil if !line
|
|
21
|
+
|
|
22
|
+
m = line.match(RE_REQUEST_LINE)
|
|
23
|
+
raise ProtocolError, 'Invalid request line' if !m
|
|
24
|
+
|
|
25
|
+
http_version = m[3]
|
|
26
|
+
raise UnsupportedHTTPVersionError, 'HTTP version not supported' if http_version != '1.1'
|
|
27
|
+
|
|
28
|
+
headers = {
|
|
29
|
+
':method' => m[1].downcase,
|
|
30
|
+
':path' => m[2]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
loop do
|
|
34
|
+
line = read_line(MAX_HEADER_LINE_LEN)
|
|
35
|
+
break if line.nil? || line.empty?
|
|
36
|
+
|
|
37
|
+
m = line.match(RE_HEADER_LINE)
|
|
38
|
+
raise ProtocolError, "Invalid header: #{line[0..2047].inspect}" if !m
|
|
39
|
+
|
|
40
|
+
headers[m[1].downcase] = m[2]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
headers
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def http_read_response_headers
|
|
47
|
+
line = read_line(MAX_RESPONSE_LINE_LEN)
|
|
48
|
+
return nil if !line
|
|
49
|
+
|
|
50
|
+
m = line.match(RE_RESPONSE_LINE)
|
|
51
|
+
raise ProtocolError, 'Invalid response line' if !m
|
|
52
|
+
|
|
53
|
+
headers = {
|
|
54
|
+
':status' => m[1].to_i
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
loop do
|
|
58
|
+
line = read_line(MAX_HEADER_LINE_LEN)
|
|
59
|
+
break if line.nil? || line.empty?
|
|
60
|
+
|
|
61
|
+
m = line.match(RE_HEADER_LINE)
|
|
62
|
+
raise ProtocolError, "Invalid header: #{line[0..2047].inspect}" if !m
|
|
63
|
+
|
|
64
|
+
k = m[1].downcase
|
|
65
|
+
if (h = headers[k])
|
|
66
|
+
(h = headers[k] = [h]) if !h.is_a?(Array)
|
|
67
|
+
h << m[2]
|
|
68
|
+
else
|
|
69
|
+
headers[k] = m[2]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
headers
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def http_read_body(headers)
|
|
77
|
+
content_length = headers['content-length']
|
|
78
|
+
if content_length
|
|
79
|
+
chunk = read(content_length.to_i)
|
|
80
|
+
return chunk
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
|
|
84
|
+
if chunked_encoding
|
|
85
|
+
buf = +''
|
|
86
|
+
while (chunk = http_read_cte_chunk(nil))
|
|
87
|
+
buf << chunk
|
|
88
|
+
end
|
|
89
|
+
return buf
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def http_skip_body(headers)
|
|
96
|
+
content_length = headers['content-length']
|
|
97
|
+
if content_length
|
|
98
|
+
return skip(content_length.to_i)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
|
|
102
|
+
if chunked_encoding
|
|
103
|
+
while http_skip_cte_chunk
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def http_read_body_chunk(headers)
|
|
111
|
+
content_length = headers['content-length']
|
|
112
|
+
if content_length
|
|
113
|
+
chunk = read(content_length.to_i)
|
|
114
|
+
return chunk
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
|
|
118
|
+
return http_read_cte_chunk(nil) if chunked_encoding
|
|
119
|
+
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def http_write_request_headers(headers)
|
|
124
|
+
method = headers[':method'] || (raise BadRequestError)
|
|
125
|
+
path = headers[':path'] || (raise BadRequestError)
|
|
126
|
+
|
|
127
|
+
lines = ["#{method} #{path} HTTP/1.1\r\n"]
|
|
128
|
+
headers.each do |k, v|
|
|
129
|
+
next if k =~ /^\:/
|
|
130
|
+
|
|
131
|
+
if v.is_a?(Array)
|
|
132
|
+
v.each { lines << "#{k}: #{it}\r\n" }
|
|
133
|
+
else
|
|
134
|
+
lines << "#{k}: #{v}\r\n"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
lines << "\r\n"
|
|
138
|
+
write(*lines)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def http_read_cte_chunk(buffer)
|
|
144
|
+
chunk_size_str = read_line(MAX_CHUNK_SIZE_LEN)
|
|
145
|
+
return nil if !chunk_size_str
|
|
146
|
+
|
|
147
|
+
chunk_size = chunk_size_str.to_i(16)
|
|
148
|
+
if chunk_size == 0
|
|
149
|
+
read_line(0)
|
|
150
|
+
return nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
chunk = read(chunk_size)
|
|
154
|
+
read_line(0)
|
|
155
|
+
|
|
156
|
+
buffer ? (buffer << chunk) : chunk
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def http_skip_cte_chunk
|
|
160
|
+
chunk_size_str = read_line(MAX_CHUNK_SIZE_LEN)
|
|
161
|
+
return if !chunk_size_str
|
|
162
|
+
|
|
163
|
+
chunk_size = chunk_size_str.to_i(16)
|
|
164
|
+
if chunk_size == 0
|
|
165
|
+
read_line(0)
|
|
166
|
+
return nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
chunk = skip(chunk_size)
|
|
170
|
+
read_line(0)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
UringMachine::IO.include(Syntropy::HTTP::ProtocolMethods)
|
data/lib/syntropy/http/server.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'syntropy/http/
|
|
3
|
+
require 'syntropy/http/server_connection'
|
|
4
4
|
|
|
5
5
|
module Syntropy
|
|
6
6
|
module HTTP
|
|
@@ -119,13 +119,13 @@ module Syntropy
|
|
|
119
119
|
end
|
|
120
120
|
|
|
121
121
|
def accept_incoming(listen_fd)
|
|
122
|
-
@machine.accept_each(listen_fd) {
|
|
122
|
+
@machine.accept_each(listen_fd) { start_connection(it) }
|
|
123
123
|
rescue UM::Terminate
|
|
124
|
-
|
|
124
|
+
@machine.shutdown(listen_fd, UM::SHUT_RD)
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
-
def
|
|
128
|
-
conn =
|
|
127
|
+
def start_connection(fd)
|
|
128
|
+
conn = ServerConnection.new(@machine, fd, @env, &@app)
|
|
129
129
|
f = @machine.spin(conn) do
|
|
130
130
|
it.run
|
|
131
131
|
ensure
|