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
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './helper'
4
+
5
+ class HTTPProtocolTest < Minitest::Test
6
+ def setup
7
+ @machine = UM.new
8
+ @r, @w = UM.pipe
9
+ @io = @machine.io(@r)
10
+ end
11
+
12
+ def teardown
13
+ @machine.close(@r) rescue nil
14
+ @machine.close(@w) rescue nil
15
+ @io = nil
16
+ @machine = nil
17
+ end
18
+
19
+ def write(str)
20
+ @machine.write(@w, str)
21
+ end
22
+ end
23
+
24
+ class HTTPProtocolRequestTest < HTTPProtocolTest
25
+ def test_http_request_headers_basic
26
+ write("GET /foo HTTP/1.1\r\nHost: bar.baz\r\n\r\n")
27
+ h = @io.http_read_request_headers
28
+ assert_equal({
29
+ ':method' => 'get',
30
+ ':path' => '/foo',
31
+ 'host' => 'bar.baz'
32
+ }, h)
33
+ end
34
+
35
+ def test_http_request_headers_bad_http_method
36
+ write("foo /foo HTTP/1.1\r\n\r\n")
37
+ assert_raises(Syntropy::ProtocolError) { @io.http_read_request_headers }
38
+ end
39
+
40
+ def test_http_request_headers_bad_path
41
+ write("get HTTP/1.1\r\n\r\n")
42
+ assert_raises(Syntropy::ProtocolError) { @io.http_read_request_headers }
43
+ end
44
+
45
+ def test_http_request_headers_bad_protocol
46
+ write("get / HTTP/1.0\r\n\r\n")
47
+ assert_raises(Syntropy::ProtocolError) { @io.http_read_request_headers }
48
+ end
49
+
50
+ def test_http_request_headers_bad_header_missing_value
51
+ write("GET /foo HTTP/1.1\r\nHost: \r\n\r\n")
52
+ assert_raises(Syntropy::ProtocolError) { @io.http_read_request_headers }
53
+ end
54
+
55
+ def test_http_request_headers_bad_header
56
+ write("GET /foo HTTP/1.1\r\nHost\r\n\r\n")
57
+ assert_raises(Syntropy::ProtocolError) { @io.http_read_request_headers }
58
+ end
59
+
60
+ def test_http_request_with_body_cl
61
+ write("POST /foo HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc")
62
+
63
+ h = @io.http_read_request_headers
64
+ assert_equal({
65
+ ':method' => 'post',
66
+ ':path' => '/foo',
67
+ 'content-length' => '3'
68
+ }, h)
69
+
70
+ b = @io.http_read_body(h)
71
+ assert_equal 'abc', b
72
+ end
73
+
74
+ def test_http_request_with_body_te
75
+ write("POST /foo HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n11\r\nabcdefghijKLMNOPQ\r\n3\r\nfoo\r\n0\r\n\r\n")
76
+
77
+ h = @io.http_read_request_headers
78
+ assert_equal({
79
+ ':method' => 'post',
80
+ ':path' => '/foo',
81
+ 'transfer-encoding' => 'chunked'
82
+ }, h)
83
+
84
+ b = @io.http_read_body(h)
85
+ assert_equal 'abcdefghijKLMNOPQfoo', b
86
+ end
87
+
88
+ def test_http_request_pipelining
89
+ write(
90
+ "GET /a HTTP/1.1\r\n\r\n" +
91
+ "POST /b HTTP/1.1\r\nHost: foo.com\r\nContent-Length: 2\r\n\r\nab" +
92
+ "PATCH /c HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n10\r\n#{'*' * 16}\r\n0\r\n\r\n" +
93
+ "GET /d HTTP/1.1\r\nFoo: bar\r\n\r\n"
94
+ )
95
+
96
+ reqs = 4.times.map {
97
+ h = @io.http_read_request_headers
98
+ b = @io.http_read_body(h)
99
+ [h, b]
100
+ }
101
+
102
+ assert_equal [
103
+ [
104
+ {
105
+ ':method' => 'get',
106
+ ':path' => '/a',
107
+ },
108
+ nil
109
+ ],
110
+ [
111
+ {
112
+ ':method' => 'post',
113
+ ':path' => '/b',
114
+ 'host' => 'foo.com',
115
+ 'content-length' => '2'
116
+ },
117
+ 'ab'
118
+ ],
119
+ [
120
+ {
121
+ ':method' => 'patch',
122
+ ':path' => '/c',
123
+ 'transfer-encoding' => 'chunked'
124
+ },
125
+ "abc#{'*' * 16}"
126
+ ],
127
+ [
128
+ {
129
+ ':method' => 'get',
130
+ ':path' => '/d',
131
+ 'foo' => 'bar'
132
+ },
133
+ nil
134
+ ],
135
+ ], reqs
136
+ end
137
+
138
+ def test_http_request_pipelining_skip_body
139
+ write(
140
+ "GET /a HTTP/1.1\r\n\r\n" +
141
+ "POST /b HTTP/1.1\r\nHost: foo.com\r\nContent-Length: 2\r\n\r\nab" +
142
+ "PATCH /c HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n10\r\n#{'*' * 16}\r\n0\r\n\r\n" +
143
+ "GET /d HTTP/1.1\r\nFoo: bar\r\n\r\n"
144
+ )
145
+
146
+ reqs = 4.times.map {
147
+ h = @io.http_read_request_headers
148
+ @io.http_skip_body(h)
149
+ h
150
+ }
151
+
152
+ assert_equal [
153
+ {
154
+ ':method' => 'get',
155
+ ':path' => '/a'
156
+ },
157
+ {
158
+ ':method' => 'post',
159
+ ':path' => '/b',
160
+ 'host' => 'foo.com',
161
+ 'content-length' => '2'
162
+ },
163
+ {
164
+ ':method' => 'patch',
165
+ ':path' => '/c',
166
+ 'transfer-encoding' => 'chunked'
167
+ },
168
+ {
169
+ ':method' => 'get',
170
+ ':path' => '/d',
171
+ 'foo' => 'bar'
172
+ }
173
+ ], reqs
174
+ end
175
+
176
+ def test_http_request_desync1
177
+ write(
178
+ "POST / HTTP/1.1\r\nHost: foo.com\r\nTransfer-Encoding: chunked\r\nContent-length: 35\r\n\r\n0\r\n\r\n" +
179
+ "GET /robots.txt HTTP/1.1\r\nX: y\r\n\r\n"
180
+ )
181
+
182
+ h = @io.http_read_request_headers
183
+ assert_equal({
184
+ ':method' => 'post',
185
+ ':path' => '/',
186
+ 'host' => 'foo.com',
187
+ 'transfer-encoding' => 'chunked',
188
+ 'content-length' => '35'
189
+ }, h)
190
+
191
+ @io.http_skip_body(h)
192
+
193
+ assert_raises(Syntropy::ProtocolError) { @io.http_read_request_headers }
194
+ end
195
+ end
196
+
197
+ class HTTPProtocolReadChunkTest < HTTPProtocolTest
198
+ def test_http_read_body_chunk_no_body
199
+ write("GET /foo HTTP/1.1\r\Host: bar.baz\r\n\r\n")
200
+ h = @io.http_read_request_headers
201
+ assert_nil @io.http_read_body_chunk(h)
202
+ end
203
+
204
+ def test_http_read_body_chunk_cl
205
+ write("POST /foo HTTP/1.1\r\Host: bar.baz\r\nContent-Length: 5\r\n\r\nabcde")
206
+ h = @io.http_read_request_headers
207
+ assert_equal 'abcde', @io.http_read_body_chunk(h)
208
+ end
209
+
210
+ def test_http_read_body_chunk_te
211
+ write("POST /foo HTTP/1.1\r\Host: bar.baz\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nabcde\r\n0\r\n\r\n")
212
+ h = @io.http_read_request_headers
213
+ assert_equal 'abcde', @io.http_read_body_chunk(h)
214
+ assert_nil @io.http_read_body_chunk(h)
215
+ end
216
+
217
+ def test_http_read_body_chunk_te2
218
+ write("POST /foo HTTP/1.1\r\Host: bar.baz\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nabcde\r\n3\r\nfgh\r\n0\r\n\r\n")
219
+ h = @io.http_read_request_headers
220
+ assert_equal 'abcde', @io.http_read_body_chunk(h)
221
+ assert_equal 'fgh', @io.http_read_body_chunk(h)
222
+ assert_nil @io.http_read_body_chunk(h)
223
+ end
224
+ end
225
+
226
+ class HTTPProtocolResponseTest < HTTPProtocolTest
227
+ def test_http_response_headers_basic
228
+ write("HTTP/1.1 200 OK\r\nHost: bar.baz\r\n\r\n")
229
+ h = @io.http_read_response_headers
230
+ assert_equal({
231
+ ':status' => 200,
232
+ 'host' => 'bar.baz'
233
+ }, h)
234
+ end
235
+
236
+ def test_http_response_headers_invalid_status_line1
237
+ write("HTTP 200 OK\r\nHost: bar.baz\r\n\r\n")
238
+ assert_raises(Syntropy::ProtocolError) { @io.http_read_response_headers }
239
+ end
240
+
241
+ def test_http_response_headers_invalid_status_line2
242
+ write("HTTP/1.1\r\nHost: bar.baz\r\n\r\n")
243
+ assert_raises(Syntropy::ProtocolError) { @io.http_read_response_headers }
244
+ end
245
+
246
+ def test_http_response_headers_invalid_status_line3
247
+ write("HTTP/1.1 ok\r\nBlahblah\r\n\r\n")
248
+ assert_raises(Syntropy::ProtocolError) { @io.http_read_response_headers }
249
+ end
250
+ end
@@ -84,8 +84,7 @@ class HTTPServerConnectionTest < Minitest::Test
84
84
  headers = req.headers
85
85
  assert_equal({
86
86
  ':method' => 'get',
87
- ':path' => '/',
88
- ':protocol' => 'http/1.1'
87
+ ':path' => '/'
89
88
  }, headers)
90
89
  end
91
90
 
@@ -94,7 +93,7 @@ class HTTPServerConnectionTest < Minitest::Test
94
93
  GET /foo HTTP/1.1
95
94
  Server: foo.com
96
95
 
97
- SCHMET /bar HTTP/1.1
96
+ GET /bar HTTP/1.1
98
97
 
99
98
 
100
99
 
@@ -108,16 +107,14 @@ class HTTPServerConnectionTest < Minitest::Test
108
107
  assert_equal({
109
108
  ':method' => 'get',
110
109
  ':path' => '/foo',
111
- ':protocol' => 'http/1.1',
112
110
  'server' => 'foo.com'
113
111
  }, headers)
114
112
 
115
113
  req1 = @reqs.shift
116
114
  headers = req1.headers
117
115
  assert_equal({
118
- ':method' => 'schmet',
119
- ':path' => '/bar',
120
- ':protocol' => 'http/1.1'
116
+ ':method' => 'get',
117
+ ':path' => '/bar'
121
118
  }, headers)
122
119
  end
123
120
 
@@ -127,7 +124,7 @@ class HTTPServerConnectionTest < Minitest::Test
127
124
  Server: foo.com
128
125
  Content-Length: 3
129
126
 
130
- abcSCHMOST /bar HTTP/1.1
127
+ abcPOST /bar HTTP/1.1
131
128
  Server: bar.com
132
129
  Content-Length: 6
133
130
 
@@ -145,7 +142,6 @@ class HTTPServerConnectionTest < Minitest::Test
145
142
  assert_equal({
146
143
  ':method' => 'post',
147
144
  ':path' => '/foo',
148
- ':protocol' => 'http/1.1',
149
145
  'server' => 'foo.com',
150
146
  'content-length' => '3',
151
147
  ':body-done-reading' => true
@@ -156,9 +152,8 @@ class HTTPServerConnectionTest < Minitest::Test
156
152
  req1 = @reqs.shift
157
153
  headers = req1.headers
158
154
  assert_equal({
159
- ':method' => 'schmost',
155
+ ':method' => 'post',
160
156
  ':path' => '/bar',
161
- ':protocol' => 'http/1.1',
162
157
  'server' => 'bar.com',
163
158
  'content-length' => '6',
164
159
  ':body-done-reading' => true
@@ -179,7 +174,7 @@ class HTTPServerConnectionTest < Minitest::Test
179
174
  de
180
175
  0
181
176
 
182
- SCHMOST /bar HTTP/1.1
177
+ POST /bar HTTP/1.1
183
178
  Server: bar.com
184
179
  Transfer-Encoding: chunked
185
180
 
@@ -203,7 +198,6 @@ class HTTPServerConnectionTest < Minitest::Test
203
198
  assert_equal({
204
199
  ':method' => 'post',
205
200
  ':path' => '/foo',
206
- ':protocol' => 'http/1.1',
207
201
  'server' => 'foo.com',
208
202
  'transfer-encoding' => 'chunked',
209
203
  ':body-done-reading' => true
@@ -214,9 +208,8 @@ class HTTPServerConnectionTest < Minitest::Test
214
208
  req1 = @reqs.shift
215
209
  headers = req1.headers
216
210
  assert_equal({
217
- ':method' => 'schmost',
211
+ ':method' => 'post',
218
212
  ':path' => '/bar',
219
- ':protocol' => 'http/1.1',
220
213
  'server' => 'bar.com',
221
214
  'transfer-encoding' => 'chunked',
222
215
  ':body-done-reading' => true
@@ -237,7 +230,7 @@ class HTTPServerConnectionTest < Minitest::Test
237
230
  de
238
231
  0
239
232
 
240
- SCHMOST /bar HTTP/1.1
233
+ POST /bar HTTP/1.1
241
234
  Server: bar.com
242
235
  Content-Length: 31
243
236
 
@@ -255,7 +248,6 @@ class HTTPServerConnectionTest < Minitest::Test
255
248
  assert_equal({
256
249
  ':method' => 'post',
257
250
  ':path' => '/foo',
258
- ':protocol' => 'http/1.1',
259
251
  'server' => 'foo.com',
260
252
  'transfer-encoding' => 'chunked',
261
253
  ':body-done-reading' => true
@@ -269,9 +261,8 @@ class HTTPServerConnectionTest < Minitest::Test
269
261
  req1 = @reqs.shift
270
262
  headers = req1.headers
271
263
  assert_equal({
272
- ':method' => 'schmost',
264
+ ':method' => 'post',
273
265
  ':path' => '/bar',
274
- ':protocol' => 'http/1.1',
275
266
  'server' => 'bar.com',
276
267
  'content-length' => '31',
277
268
  ':body-done-reading' => true
@@ -605,7 +596,7 @@ class HTTPServerConnectionTest < Minitest::Test
605
596
 
606
597
  def test_set_cookie_single
607
598
  @hook = ->(req) {
608
- req.set_cookie('foo=bar; HttpOnly')
599
+ req.set_cookie('foo', 'bar; HttpOnly')
609
600
  req.respond('foo')
610
601
  }
611
602
 
@@ -619,7 +610,8 @@ class HTTPServerConnectionTest < Minitest::Test
619
610
 
620
611
  def test_set_cookie_multi1
621
612
  @hook = ->(req) {
622
- req.set_cookie('foo=bar; HttpOnly', 'bar=baz')
613
+ req.set_cookie('foo', 'bar; HttpOnly')
614
+ req.set_cookie('bar', 'baz')
623
615
  req.respond('foo')
624
616
  }
625
617
 
@@ -633,9 +625,11 @@ class HTTPServerConnectionTest < Minitest::Test
633
625
 
634
626
  def test_set_cookie_multi2
635
627
  @hook = ->(req) {
636
- req.set_cookie('a=1', 'b=2')
637
- req.set_cookie('c=3')
638
- req.set_cookie('d=4', 'e=5')
628
+ req.set_cookie('a', '1')
629
+ req.set_cookie('b', '2')
630
+ req.set_cookie('c', '3')
631
+ req.set_cookie('d', '4')
632
+ req.set_cookie('e', '5')
639
633
  req.respond('foo')
640
634
  }
641
635
 
@@ -11,7 +11,7 @@ class JSONAPITest < Minitest::Test
11
11
  end
12
12
 
13
13
  def bar!(req)
14
- @value = req.query[:v]
14
+ @value = req.query['v']
15
15
  true
16
16
  end
17
17
  end
@@ -79,4 +79,35 @@ class ModuleTest < Minitest::Test
79
79
  mod = @loader.load('mod/foo/index')
80
80
  assert_equal '/mod/foo', mod.env[:ref]
81
81
  end
82
+
83
+ def test_list
84
+ list = @loader.list('_layout')
85
+ assert_equal ['_layout/default'], list
86
+
87
+ list = @loader.list('_lib')
88
+ assert_equal [
89
+ '_lib/callable',
90
+ '_lib/dep',
91
+ '_lib/env',
92
+ '_lib/klass',
93
+ '_lib/missing-export',
94
+ '_lib/self',
95
+ ], list
96
+
97
+ list = @loader.list('about')
98
+ assert_equal [
99
+ 'about/_error',
100
+ 'about/index',
101
+ 'about/raise'
102
+ ], list
103
+
104
+ list = @loader.list('assets')
105
+ assert_equal [], list
106
+
107
+ list = @loader.list('mod')
108
+ assert_equal [], list
109
+
110
+ list = @loader.list('mod/bar')
111
+ assert_equal ['mod/bar/index+'], list
112
+ end
82
113
  end
data/test/test_request.rb CHANGED
@@ -10,20 +10,20 @@ class RequestInfoTest < Minitest::Test
10
10
 
11
11
  r = Syntropy::MockAdapter.mock(':path' => '/test/path?a=1&b=2&c=3%2f4')
12
12
  assert_equal '/test/path', r.path
13
- assert_equal({ a: '1', b: '2', c: '3/4' }, r.query)
13
+ assert_equal({ 'a' => '1', 'b' => '2', 'c' => '3/4' }, r.query)
14
14
  end
15
15
 
16
16
  def test_query
17
17
  r = Syntropy::MockAdapter.mock(':path' => '/GponForm/diag_Form?images/')
18
18
  assert_equal '/GponForm/diag_Form', r.path
19
- assert_equal({:'images/' => true}, r.query)
19
+ assert_equal({ 'images/' => true }, r.query)
20
20
 
21
21
  r = Syntropy::MockAdapter.mock(':path' => '/?a=1&b=2')
22
22
  assert_equal '/', r.path
23
- assert_equal({a: '1', b: '2'}, r.query)
23
+ assert_equal({ 'a' => '1', 'b' => '2'}, r.query)
24
24
 
25
25
  r = Syntropy::MockAdapter.mock(':path' => '/?l=a&t=&x=42')
26
- assert_equal({l: 'a', t: '', x: '42'}, r.query)
26
+ assert_equal({ 'l' => 'a', 't' => '', 'x' => '42'}, r.query)
27
27
  end
28
28
 
29
29
  def test_host
@@ -157,6 +157,9 @@ class ValidationTest < Minitest::Test
157
157
  assert_equal 'foo', @req.validate('foo', [String, nil])
158
158
  assert_nil @req.validate(nil, [String, nil])
159
159
 
160
+ assert_equal 'foo', @req.validate('foo', String, /.+/)
161
+ assert_raises(VE) { @req.validate('', String, /.+/) }
162
+
160
163
  assert_equal 123, @req.validate('123', Integer)
161
164
  assert_raises(VE) { @req.validate('a123', Integer) }
162
165
  assert_equal 123, @req.validate('123', Integer, 120..125)