syntropy 0.31.0 → 0.33.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +20 -0
  4. data/TODO.md +7 -1
  5. data/cmd/console.rb +77 -0
  6. data/cmd/serve.rb +1 -3
  7. data/cmd/test.rb +76 -20
  8. data/examples/blog/app/_layout/default.rb +11 -0
  9. data/examples/blog/app/_lib/post_store.rb +47 -0
  10. data/examples/blog/app/_schema/2026-01-01-initial.rb +9 -0
  11. data/examples/blog/app/_setup.rb +4 -0
  12. data/examples/blog/app/index.rb +7 -0
  13. data/examples/blog/app/posts/[id]/edit.rb +33 -0
  14. data/examples/blog/app/posts/[id]/index.rb +61 -0
  15. data/examples/blog/app/posts/index.rb +40 -0
  16. data/examples/blog/app/posts/new.rb +29 -0
  17. data/examples/mcp-oauth/README.md +3 -3
  18. data/examples/mcp-oauth/app/mcp.rb +55 -8
  19. data/examples/mcp-oauth/app/oauth/authorize.rb +0 -8
  20. data/examples/mcp-oauth/app/oauth/register.rb +0 -1
  21. data/examples/mcp-oauth/test/test_app.rb +2 -20
  22. data/examples/mcp-oauth/test/test_oauth.rb +93 -217
  23. data/lib/syntropy/app.rb +23 -9
  24. data/lib/syntropy/db/connection_pool.rb +71 -0
  25. data/lib/syntropy/db/schema.rb +92 -0
  26. data/lib/syntropy/db/store.rb +31 -0
  27. data/lib/syntropy/http/io_extensions.rb +33 -5
  28. data/lib/syntropy/http/server_connection.rb +21 -62
  29. data/lib/syntropy/{module.rb → module_loader.rb} +48 -8
  30. data/lib/syntropy/request/request_info.rb +3 -4
  31. data/lib/syntropy/request/response.rb +2 -2
  32. data/lib/syntropy/request/session.rb +113 -0
  33. data/lib/syntropy/request/validation.rb +1 -2
  34. data/lib/syntropy/request.rb +9 -0
  35. data/lib/syntropy/test.rb +84 -1
  36. data/lib/syntropy/version.rb +1 -1
  37. data/lib/syntropy.rb +4 -2
  38. data/syntropy.gemspec +3 -1
  39. data/test/app/_hook.rb +1 -1
  40. data/test/app/by_method.rb +9 -0
  41. data/test/app_setup/_setup.rb +7 -0
  42. data/test/app_setup/index.rb +1 -0
  43. data/test/app_with_schema/_schema/2026-01-02-foo.rb +12 -0
  44. data/test/app_with_schema/_schema/2026-05-30-bar.rb +7 -0
  45. data/test/schema/2026-01-02-foo.rb +12 -0
  46. data/test/schema/2026-05-30-bar.rb +7 -0
  47. data/test/test_app.rb +58 -3
  48. data/test/{test_connection_pool.rb → test_db_connection_pool.rb} +7 -2
  49. data/test/test_db_schema.rb +96 -0
  50. data/test/test_db_store.rb +24 -0
  51. data/test/test_http_protocol.rb +250 -0
  52. data/test/test_http_server_connection.rb +18 -24
  53. data/test/test_json_api.rb +1 -1
  54. data/test/{test_module.rb → test_module_loader.rb} +31 -0
  55. data/test/test_request.rb +7 -4
  56. data/test/test_request_session.rb +254 -0
  57. data/test/test_server.rb +9 -13
  58. metadata +63 -12
  59. data/examples/mcp-oauth/test/helper.rb +0 -9
  60. data/lib/syntropy/connection_pool.rb +0 -61
data/lib/syntropy/test.rb CHANGED
@@ -3,12 +3,84 @@
3
3
  require 'syntropy'
4
4
  require 'syntropy/request/mock_adapter'
5
5
  require 'minitest'
6
+ require 'json'
7
+ require 'uri'
6
8
 
7
9
  module Syntropy
10
+ class Test < Minitest::Test
11
+ HTTP = Syntropy::HTTP
12
+
13
+ def self.env=(env)
14
+ @@env = env
15
+ end
16
+
17
+ attr_reader :machine, :app
18
+
19
+ def env
20
+ @@env
21
+ end
22
+
23
+ def load_module(ref)
24
+ app.module_loader.load(ref)
25
+ end
26
+
27
+ def http_request(headers, body = nil)
28
+ @test_harness.request(headers, body)
29
+ end
30
+
31
+ def get(path, **headers)
32
+ http_request(
33
+ headers.merge(
34
+ ':method' => 'GET',
35
+ ':path' => path
36
+ )
37
+ )
38
+ end
39
+
40
+ def post(path, content_type, body, **headers)
41
+ headers = headers.merge('content-type' => content_type) if content_type
42
+ http_request(
43
+ headers.merge(
44
+ {
45
+ ':method' => 'POST',
46
+ ':path' => path
47
+ }
48
+ ),
49
+ body
50
+ )
51
+ end
52
+
53
+ def post_json(path, obj, **)
54
+ post(path, 'application/json', JSON.dump(obj), **)
55
+ end
56
+
57
+ def post_form(path, form, **)
58
+ post(path, 'application/x-www-form-urlencoded', URI.encode_www_form(form), **)
59
+ end
60
+
61
+ def setup
62
+ raise 'Environment not set' if !@@env
63
+
64
+ @machine = UM.new
65
+ @app = Syntropy::App.new(
66
+ root_dir: @@env[:root_dir],
67
+ mount_path: '/',
68
+ machine: @machine
69
+ )
70
+ @test_harness = Syntropy::TestHarness.new(@app)
71
+ end
72
+
73
+ def teardown
74
+ @machine = nil
75
+ @app = nil
76
+ @test_harness = nil
77
+ end
78
+ end
79
+
8
80
  class TestHarness
9
81
  def initialize(app)
10
82
  @app = app
11
- @app.test_mode = true
83
+ @app.raise_internal_server_error = true if @app.respond_to?(:raise_internal_server_error=)
12
84
  end
13
85
 
14
86
  def request(headers, body = nil)
@@ -17,6 +89,17 @@ module Syntropy
17
89
  req
18
90
  end
19
91
 
92
+ def no_raise_internal_server_error
93
+ return yield if !@app.respond_to?(:raise_internal_server_error=)
94
+
95
+ begin
96
+ @app.raise_internal_server_error = false
97
+ yield
98
+ ensure
99
+ @app.raise_internal_server_error = true
100
+ end
101
+ end
102
+
20
103
  private
21
104
 
22
105
  def mock_req(headers, body = nil)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syntropy
4
- VERSION = '0.31.0'
4
+ VERSION = '0.33.0'
5
5
  end
data/lib/syntropy.rb CHANGED
@@ -8,10 +8,12 @@ require 'syntropy/logger'
8
8
  require 'syntropy/http'
9
9
  require 'syntropy/mime_types'
10
10
  require 'syntropy/app'
11
- require 'syntropy/connection_pool'
11
+ require 'syntropy/db/connection_pool'
12
+ require 'syntropy/db/schema'
13
+ require 'syntropy/db/store'
12
14
  require 'syntropy/errors'
13
15
  require 'syntropy/markdown'
14
- require 'syntropy/module'
16
+ require 'syntropy/module_loader'
15
17
  require 'syntropy/papercraft_extensions'
16
18
  require 'syntropy/routing_tree'
17
19
  require 'syntropy/json_api'
data/syntropy.gemspec CHANGED
@@ -24,11 +24,13 @@ Gem::Specification.new do |s|
24
24
  s.add_dependency 'extralite', '~>2.14'
25
25
  s.add_dependency 'papercraft', '~>3.2.0'
26
26
  s.add_dependency 'uringmachine', '~>1.0.2'
27
- s.add_dependency 'escape_utils', '1.3.0'
28
27
 
29
28
  s.add_dependency 'json'
29
+ s.add_dependency 'base64'
30
30
  s.add_dependency 'logger'
31
+ s.add_dependency 'irb'
31
32
 
32
33
  s.add_development_dependency 'minitest', '~>6.0.1'
33
34
  s.add_development_dependency 'rake', '~>13.3.1'
35
+ s.add_development_dependency 'solargraph'
34
36
  end
data/test/app/_hook.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  export ->(req, proc) {
2
- req.ctx[:foo] = req.query[:foo]
2
+ req.ctx[:foo] = req.query['foo']
3
3
  proc.(req)
4
4
  }
@@ -0,0 +1,9 @@
1
+ export http_methods
2
+
3
+ def get(req)
4
+ req.respond('foo')
5
+ end
6
+
7
+ def post(req)
8
+ req.respond('bar')
9
+ end
@@ -0,0 +1,7 @@
1
+ @app.env[:setup_imported] = true
2
+
3
+ class << @app
4
+ def foobar
5
+ :foobar
6
+ end
7
+ end
@@ -0,0 +1 @@
1
+ export ->(req) { req.respond('foo') }
@@ -0,0 +1,12 @@
1
+ export ->(db) {
2
+ db.execute <<~SQL
3
+ create table posts (
4
+ id integer primary key autoincrement,
5
+ title text,
6
+ body text
7
+ );
8
+
9
+ insert into posts (title, body)
10
+ values ('foo', 'bar');
11
+ SQL
12
+ }
@@ -0,0 +1,7 @@
1
+ export ->(db) {
2
+ db.execute <<~SQL
3
+ update posts
4
+ set body = 'baz'
5
+ where title = 'foo';
6
+ SQL
7
+ }
@@ -0,0 +1,12 @@
1
+ export ->(db) {
2
+ db.execute <<~SQL
3
+ create table posts (
4
+ id integer primary key autoincrement,
5
+ title text,
6
+ body text
7
+ );
8
+
9
+ insert into posts (title, body)
10
+ values ('foo', 'bar');
11
+ SQL
12
+ }
@@ -0,0 +1,7 @@
1
+ export ->(db) {
2
+ db.execute <<~SQL
3
+ update posts
4
+ set body = 'baz'
5
+ where title = 'foo';
6
+ SQL
7
+ }
data/test/test_app.rb CHANGED
@@ -114,12 +114,25 @@ class AppTest < Minitest::Test
114
114
  req = @test_harness.request(':method' => 'GET', ':path' => '/test/rss')
115
115
  assert_equal '<link>foo</link>', req.response_body
116
116
 
117
- req = @test_harness.request(':method' => 'GET', ':path' => '/test/bad_mod')
117
+ req = @test_harness.no_raise_internal_server_error {
118
+ @test_harness.request(':method' => 'GET', ':path' => '/test/bad_mod')
119
+ }
118
120
  assert_equal HTTP::INTERNAL_SERVER_ERROR, req.response_status
119
121
 
120
122
  req = @test_harness.request(':method' => 'GET', ':path' => '/test/.well-known/foo')
121
123
  assert_equal HTTP::OK, req.response_status
122
124
  assert_equal 'foo', req.response_body
125
+
126
+ req = @test_harness.request(':method' => 'GET', ':path' => '/test/by_method')
127
+ assert_equal HTTP::OK, req.response_status
128
+ assert_equal 'foo', req.response_body
129
+
130
+ req = @test_harness.request(':method' => 'POST', ':path' => '/test/by_method')
131
+ assert_equal HTTP::OK, req.response_status
132
+ assert_equal 'bar', req.response_body
133
+
134
+ req = @test_harness.request(':method' => 'DELETE', ':path' => '/test/by_method')
135
+ assert_equal HTTP::METHOD_NOT_ALLOWED, req.response_status
123
136
  end
124
137
 
125
138
  def test_automatic_redirect_on_trailing_slash
@@ -129,14 +142,14 @@ class AppTest < Minitest::Test
129
142
  end
130
143
 
131
144
  def test_app_file_watching
132
- @machine.sleep 0.3
145
+ @machine.sleep 0.2
133
146
 
134
147
  req = @test_harness.request(':method' => 'GET', ':path' => @tmp_path)
135
148
  assert_equal 'foo', req.response_body
136
149
 
137
150
  orig_body = IO.read(@tmp_fn)
138
151
  IO.write(@tmp_fn, orig_body.gsub('foo', 'bar'))
139
- @machine.sleep(0.3)
152
+ @machine.sleep(0.2)
140
153
 
141
154
  req = @test_harness.request(':method' => 'GET', ':path' => @tmp_path)
142
155
  assert_equal 'bar', req.response_body
@@ -309,3 +322,45 @@ class AppDependenciesTest < Minitest::Test
309
322
  assert_equal HTTP::OK, req.response_status
310
323
  end
311
324
  end
325
+
326
+ class AppDBSetupDBTest < Minitest::Test
327
+ HTTP = Syntropy::HTTP
328
+
329
+ APP_ROOT = File.join(__dir__, 'app_with_schema')
330
+
331
+ def test_app_setup_db
332
+ machine = UM.new
333
+
334
+ app = Syntropy::App.new(
335
+ root_dir: APP_ROOT,
336
+ mount_path: '/test',
337
+ machine: machine
338
+ )
339
+
340
+ assert_equal false, app.respond_to?(:connection_pool)
341
+ assert_equal false, app.respond_to?(:schema)
342
+
343
+ fn = "/tmp/#{rand(100000)}.db"
344
+
345
+ app.setup_db(
346
+ db_path: fn,
347
+ schema_root: '_schema'
348
+ )
349
+
350
+ assert_equal true, app.respond_to?(:connection_pool)
351
+ assert_equal fn, app.connection_pool.with_db { it.filename }
352
+
353
+ assert_equal true, app.respond_to?(:schema)
354
+ app.schema.apply(app.connection_pool)
355
+ assert_equal '2026-05-30-bar', app.schema.current_version(app.connection_pool)
356
+
357
+ assert_equal [
358
+ {
359
+ id: 1,
360
+ title: 'foo',
361
+ body: 'baz'
362
+ }
363
+ ], app.connection_pool.query('select id, title, body from posts')
364
+
365
+ end
366
+ end
@@ -2,11 +2,11 @@
2
2
 
3
3
  require_relative 'helper'
4
4
 
5
- class ConnectionPoolTest < Minitest::Test
5
+ class DBConnectionPoolTest < Minitest::Test
6
6
  def setup
7
7
  @machine = UM.new
8
8
  @fn = "/tmp/#{rand(100000)}.db"
9
- @cp = Syntropy::ConnectionPool.new(@machine, @fn, 4)
9
+ @cp = Syntropy::DB::ConnectionPool.new(@machine, @fn, 4)
10
10
 
11
11
  FileUtils.rm(@fn) rescue nil
12
12
  @standalone_db = Extralite::Database.new(@fn)
@@ -14,6 +14,11 @@ class ConnectionPoolTest < Minitest::Test
14
14
  @standalone_db.execute("insert into foo values (1, 2, 3)")
15
15
  end
16
16
 
17
+ def teardown
18
+ @standalone_db.close
19
+ @cp.close
20
+ end
21
+
17
22
  def test_with_db
18
23
  assert_equal 0, @cp.count
19
24
 
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ class DBSchemaTest < Minitest::Test
6
+ def setup
7
+ @machine = UM.new
8
+ @fn = "/tmp/#{rand(100000)}.db"
9
+ FileUtils.rm(@fn) rescue nil
10
+ @cp = Syntropy::DB::ConnectionPool.new(@machine, @fn, 4)
11
+ end
12
+
13
+ def teardown
14
+ @cp.close
15
+ end
16
+
17
+ def test_db_schema_initial
18
+ schema = Syntropy::DB::Schema.new do
19
+ initial do |db|
20
+ db.execute <<~SQL
21
+ create table posts (
22
+ id integer primary key autoincrement,
23
+ title text,
24
+ body text
25
+ )
26
+ SQL
27
+ end
28
+ end
29
+
30
+ assert_nil schema.current_version(@cp)
31
+ schema.apply(@cp)
32
+ assert_equal '0000', schema.current_version(@cp)
33
+
34
+ assert_equal [], @cp.query('select id, title, body from posts')
35
+ end
36
+
37
+ def test_db_schema_version_blocks
38
+ schema = Syntropy::DB::Schema.new do
39
+ initial do |db|
40
+ db.execute <<~SQL
41
+ create table posts (
42
+ id integer primary key autoincrement,
43
+ title text,
44
+ body text
45
+ )
46
+ SQL
47
+ end
48
+
49
+ version('2026-05-30') do |db|
50
+ db.execute <<~SQL
51
+ insert into posts (title, body)
52
+ values ('foo', 'bar')
53
+ SQL
54
+ end
55
+
56
+ version('2026-05-31') do |db|
57
+ db.execute <<~SQL
58
+ update posts
59
+ set body = 'baz'
60
+ where title = 'foo'
61
+ SQL
62
+ end
63
+ end
64
+
65
+ assert_nil schema.current_version(@cp)
66
+ schema.apply(@cp)
67
+ assert_equal '2026-05-31', schema.current_version(@cp)
68
+
69
+ assert_equal [
70
+ {
71
+ id: 1,
72
+ title: 'foo',
73
+ body: 'baz'
74
+ }
75
+ ], @cp.query('select id, title, body from posts')
76
+ end
77
+
78
+ def test_schema_from_module_files
79
+ module_loader = Syntropy::ModuleLoader.new({
80
+ root_dir: File.join(__dir__, 'schema')
81
+ })
82
+ schema = Syntropy::DB::Schema.new(module_loader:, schema_root: '/')
83
+
84
+ assert_nil schema.current_version(@cp)
85
+ schema.apply(@cp)
86
+ assert_equal '2026-05-30-bar', schema.current_version(@cp)
87
+
88
+ assert_equal [
89
+ {
90
+ id: 1,
91
+ title: 'foo',
92
+ body: 'baz'
93
+ }
94
+ ], @cp.query('select id, title, body from posts')
95
+ end
96
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ class DBStoreTest < Minitest::Test
6
+ def setup
7
+ @machine = UM.new
8
+ @fn = "/tmp/#{rand(100000)}.db"
9
+ FileUtils.rm(@fn) rescue nil
10
+ @cp = Syntropy::DB::ConnectionPool.new(@machine, @fn, 4)
11
+ end
12
+
13
+ def teardown
14
+ @cp.close
15
+ end
16
+
17
+ def test_db_store
18
+ store = Syntropy::DB::Store.new(@cp)
19
+
20
+ assert_equal [{a: 42}], store.query("select ? as a", 42)
21
+ assert_equal({a: 42}, store.query_single_row("select ? as a", 42))
22
+ assert_equal 42, store.query_single_value("select ?", 42)
23
+ end
24
+ end