syntropy 0.38.1 → 0.40.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +2 -0
  4. data/TODO.md +55 -144
  5. data/cmd/console.rb +2 -4
  6. data/cmd/new/template/config/production.rb +2 -1
  7. data/cmd/new.rb +1 -1
  8. data/cmd/serve.rb +29 -4
  9. data/cmd/test.rb +12 -4
  10. data/examples/agent/Dockerfile +25 -0
  11. data/examples/agent/Gemfile +3 -0
  12. data/examples/agent/README.md +40 -0
  13. data/examples/agent/TODO.md +5 -0
  14. data/examples/agent/app/_layout/default.rb +15 -0
  15. data/examples/agent/app/_lib/storage.rb +13 -0
  16. data/examples/agent/app/_schema/2026-01-01-initial.rb +9 -0
  17. data/examples/agent/app/assets/agent.js +63 -0
  18. data/examples/agent/app/assets/minigfm.js +207 -0
  19. data/examples/agent/app/assets/model.js +45 -0
  20. data/examples/agent/app/assets/style.css +25 -0
  21. data/examples/agent/app/assets/tools.js +74 -0
  22. data/examples/agent/app/assets/ui.js +50 -0
  23. data/examples/agent/app/index.rb +19 -0
  24. data/examples/agent/app/test.rb +7 -0
  25. data/examples/agent/config/Caddyfile +5 -0
  26. data/examples/agent/config/development.rb +5 -0
  27. data/examples/agent/config/production.rb +5 -0
  28. data/examples/agent/config/test.rb +5 -0
  29. data/examples/agent/docker-compose.yml +71 -0
  30. data/examples/agent/response_example.json +108 -0
  31. data/examples/agent/test/test_app.rb +14 -0
  32. data/examples/blog/app/foo.rb +1 -0
  33. data/examples/blog/app/posts/[id]/edit.rb +3 -3
  34. data/examples/blog/app/posts/[id]/index.rb +8 -10
  35. data/examples/blog/app/posts/index.rb +6 -8
  36. data/examples/blog/app/posts/new.rb +3 -3
  37. data/examples/blog/config/production.rb +2 -1
  38. data/lib/syntropy/app.rb +12 -60
  39. data/lib/syntropy/applets/builtin/auto_refresh/watch.js +1 -1
  40. data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +7 -0
  41. data/lib/syntropy/controller_extensions.rb +1 -1
  42. data/lib/syntropy/dev_mode.rb +8 -3
  43. data/lib/syntropy/http/client.rb +17 -1
  44. data/lib/syntropy/http/client_connection.rb +16 -0
  45. data/lib/syntropy/http/io_extensions.rb +32 -4
  46. data/lib/syntropy/http/server.rb +55 -24
  47. data/lib/syntropy/http/server_connection.rb +72 -2
  48. data/lib/syntropy/logger.rb +8 -0
  49. data/lib/syntropy/markdown.rb +139 -11
  50. data/lib/syntropy/module_loader.rb +110 -68
  51. data/lib/syntropy/request/request_info.rb +8 -0
  52. data/lib/syntropy/request/response.rb +76 -6
  53. data/lib/syntropy/request/validation.rb +6 -0
  54. data/lib/syntropy/storage/connection_pool.rb +34 -0
  55. data/lib/syntropy/test.rb +14 -5
  56. data/lib/syntropy/version.rb +1 -1
  57. data/lib/syntropy.rb +9 -2
  58. data/syntropy.gemspec +4 -4
  59. data/test/fixtures/app/_layout/default.rb +1 -1
  60. data/test/fixtures/app/_layout/kuku.rb +8 -0
  61. data/test/fixtures/app/_lib/circular/a.rb +2 -0
  62. data/test/fixtures/app/_lib/circular/b.rb +2 -0
  63. data/test/fixtures/app/_lib/circular/c.rb +2 -0
  64. data/test/fixtures/app/mod/concurrent.rb +9 -0
  65. data/test/test_app.rb +1 -1
  66. data/test/test_caching.rb +3 -3
  67. data/test/test_markdown.rb +268 -0
  68. data/test/test_module_loader.rb +32 -4
  69. data/test/test_request.rb +7 -0
  70. data/test/test_response.rb +30 -0
  71. data/test/test_schema.rb +1 -0
  72. metadata +40 -11
@@ -11,6 +11,10 @@ module Syntropy
11
11
  module ResponseMethods
12
12
  WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
13
13
 
14
+ # Upgrades a request to the WebSocket protocol.
15
+ #
16
+ # @param custom_headers [Hash, nil] additional response headers
17
+ # @return [void]
14
18
  def upgrade_to_websocket(custom_headers = nil)
15
19
  key = "#{headers['sec-websocket-key']}#{WEBSOCKET_GUID}"
16
20
  upgrade_headers = {
@@ -22,20 +26,42 @@ module Syntropy
22
26
  adapter.websocket_connection(self)
23
27
  end
24
28
 
29
+ # Responds with a redirect to the given URL. If a relative path is provided
30
+ # as the URL, the absolute URL path will be calculated relative to the
31
+ # current request's path.
32
+ #
33
+ # @param url [String] redirect URL
34
+ # @param status [Integer] HTTP status code
35
+ # @return [void]
25
36
  def redirect(url, status = HTTP::FOUND)
37
+ url = File.expand_path(File.join(path, url)) if url =~ /^\./
26
38
  respond(nil, ':status' => status, 'Location' => url)
27
39
  end
28
40
 
41
+ # Redirects to the same URL, except with HTTPS.
42
+ #
43
+ # @param status [Integer] HTTP status code
44
+ # @return [void]
29
45
  def redirect_to_https(status = HTTP::MOVED_PERMANENTLY)
30
46
  secure_uri = "https://#{host}#{uri}"
31
47
  redirect(secure_uri, status)
32
48
  end
33
49
 
50
+ # Redirect to the same path and query, on a different host.
51
+ #
52
+ # @param new_host [String] new host name
53
+ # @param status [Integer] HTTP status code
54
+ # @return [void]
34
55
  def redirect_to_host(new_host, status = HTTP::FOUND)
35
56
  secure_uri = "//#{new_host}#{uri}"
36
57
  redirect(secure_uri, status)
37
58
  end
38
59
 
60
+ # Serves a static file as a response, with cache headers.
61
+ #
62
+ # @param path [String] file path
63
+ # @param opts [Hash] options
64
+ # @return [void]
39
65
  def serve_file(path, opts = {})
40
66
  full_path = file_full_path(path, opts)
41
67
  stat = File.stat(full_path)
@@ -58,6 +84,12 @@ module Syntropy
58
84
  respond(nil, ':status' => HTTP::NOT_FOUND)
59
85
  end
60
86
 
87
+ # Validates a static file cache against the request cache headers. Returns
88
+ # true if cache headers are valid.
89
+ #
90
+ # @param etag [String] ETag
91
+ # @param last_modified [String] last-modified value
92
+ # @return [bool] are cache headers valid
61
93
  def validate_static_file_cache(etag, last_modified)
62
94
  if (none_match = headers['if-none-match'])
63
95
  return true if none_match == etag
@@ -69,6 +101,11 @@ module Syntropy
69
101
  false
70
102
  end
71
103
 
104
+ # Returns a fully qualified file path based on the given options.
105
+ #
106
+ # @param path [String] file path
107
+ # @param opts [Hash] options hash
108
+ # @return [String] qualified file path
72
109
  def file_full_path(path, opts)
73
110
  if (base_path = opts[:base_path])
74
111
  File.join(opts[:base_path], path)
@@ -77,10 +114,13 @@ module Syntropy
77
114
  end
78
115
  end
79
116
 
80
- def serve_io(io, opts)
81
- respond(io.read, opts[:headers] || {})
82
- end
83
-
117
+ # Renders a static file response.
118
+ #
119
+ # @param path [String] file path
120
+ # @param etag [String] ETag
121
+ # @param last_modified [String] last-modified value
122
+ # @param opts [Hash] options
123
+ # @return [void]
84
124
  def respond_with_static_file(path, etag, last_modified, opts)
85
125
  cache_headers = (etag || last_modified) ? {
86
126
  'etag' => etag,
@@ -90,14 +130,29 @@ module Syntropy
90
130
  adapter.respond_with_static_file(self, path, opts, cache_headers)
91
131
  end
92
132
 
133
+ # Set additional response headers.
134
+ #
135
+ # @param headers [Hash] response headers
136
+ # @return [void]
93
137
  def set_response_headers(headers)
94
138
  adapter.set_response_headers(headers)
95
139
  end
96
140
 
97
- def set_cookie(k, v)
98
- adapter.set_cookie(k, v)
141
+ # Sets a response cookie.
142
+ #
143
+ # @param key [String] cookie name
144
+ # @param value [String] cookie value
145
+ # @return [void]
146
+ def set_cookie(key, value)
147
+ adapter.set_cookie(key, value)
99
148
  end
100
149
 
150
+ # Upgrades the connection to the given protocol, yielding the connection to
151
+ # the given block.
152
+ #
153
+ # @param protocol [String] upgraded protocol
154
+ # @param custom_headers [Hash, nil] additional response headers
155
+ # @return [void]
101
156
  def upgrade(protocol, custom_headers = nil, &block)
102
157
  upgrade_headers = {
103
158
  ':status' => HTTP::SWITCHING_PROTOCOLS,
@@ -168,6 +223,11 @@ module Syntropy
168
223
  end
169
224
  end
170
225
 
226
+ # Renders an HTML repsonse with HTML content type.
227
+ #
228
+ # @param html [String] HTML response body
229
+ # @param headers [Hash] additional response headers
230
+ # @return [void]
171
231
  def respond_html(html, **headers)
172
232
  respond(
173
233
  html,
@@ -176,6 +236,11 @@ module Syntropy
176
236
  )
177
237
  end
178
238
 
239
+ # Renders a JSON repsonse with JSON content type.
240
+ #
241
+ # @param obj [any] obj to render into JSON
242
+ # @param headers [Hash] additional response headers
243
+ # @return [void]
179
244
  def respond_json(obj, **headers)
180
245
  respond(
181
246
  JSON.dump(obj),
@@ -184,6 +249,11 @@ module Syntropy
184
249
  )
185
250
  end
186
251
 
252
+ # Renders a nicely-formatted JSON repsonse with JSON content type.
253
+ #
254
+ # @param obj [any] obj to render into JSON
255
+ # @param headers [Hash] additional response headers
256
+ # @return [void]
187
257
  def json_pretty_response(obj, **headers)
188
258
  respond(
189
259
  JSON.pretty_generate(obj),
@@ -18,6 +18,12 @@ module Syntropy
18
18
  raise Syntropy::Error.method_not_allowed
19
19
  end
20
20
 
21
+ # Validates the request's content-type against the given accepted content
22
+ # types. If the content type does not match the given accepted list, raises
23
+ # an error.
24
+ #
25
+ # @param accepted [Array<String>] array of accepted content types
26
+ # @return [String] accepted content type
21
27
  def validate_content_type(*accepted)
22
28
  ct = content_type
23
29
  return ct if accepted.include?(ct)
@@ -8,6 +8,12 @@ module Syntropy
8
8
  class ConnectionPool
9
9
  attr_reader :count
10
10
 
11
+ # Initializes the connection pool.
12
+ #
13
+ # @param machine [UringMachine] machine instance
14
+ # @param fn [String] database filename
15
+ # @param max_conn [Integer] maximum number of connections
16
+ # @return [void]
11
17
  def initialize(machine, fn, max_conn)
12
18
  @machine = machine
13
19
  @fn = fn
@@ -17,6 +23,11 @@ module Syntropy
17
23
  @key = :"connection_pool_#{object_id}"
18
24
  end
19
25
 
26
+ # Checks out a database connection, passing it to the given block. This
27
+ # method is reentrant - called from the same fiber, it will yield the same
28
+ # database connection.
29
+ #
30
+ # @return [Extralite::Database] database connection
20
31
  def with_db
21
32
  if (db = Thread.current[@key])
22
33
  @machine.snooze
@@ -33,14 +44,27 @@ module Syntropy
33
44
  end
34
45
  end
35
46
 
47
+ # Performs the given query by acquiring a database connection and running
48
+ # the query.
49
+ #
50
+ # @param sql [String] SQL query
51
+ # @return [Array<Hash>] result rows
36
52
  def query(sql, *, **, &)
37
53
  with_db { it.query(sql, *, **, &) }
38
54
  end
39
55
 
56
+ # Executes the given query by acquiring a database connection and running
57
+ # the query.
58
+ #
59
+ # @param sql [String] SQL query
60
+ # @return [Integer] number of changed rows
40
61
  def execute(sql, *, **)
41
62
  with_db { it.execute(sql, *, **) }
42
63
  end
43
64
 
65
+ # Closes the connection pool by removing all connections.
66
+ #
67
+ # @return [void]
44
68
  def close
45
69
  while @queue.count > 0
46
70
  db = @machine.shift(@queue)
@@ -51,16 +75,26 @@ module Syntropy
51
75
 
52
76
  private
53
77
 
78
+ # Checks out a connection from the pool.
79
+ #
80
+ # @return [Extralite::Database] database connection
54
81
  def checkout
55
82
  return make_db_instance if @queue.count == 0 && @count < @max_conn
56
83
 
57
84
  @machine.shift(@queue)
58
85
  end
59
86
 
87
+ # Checks in a connection to the pool.
88
+ #
89
+ # @param db [Extralite::Database] database connection
90
+ # @return [void]
60
91
  def checkin(db)
61
92
  @machine.push(@queue, db)
62
93
  end
63
94
 
95
+ # Creates a database connection.
96
+ #
97
+ # @return [Extralite::Database] database connection
64
98
  def make_db_instance
65
99
  Extralite::Database.new(@fn, wal: true).tap do
66
100
  @count += 1
data/lib/syntropy/test.rb CHANGED
@@ -12,6 +12,14 @@ module Syntropy
12
12
  HTTP = Syntropy::HTTP
13
13
 
14
14
  class << self
15
+ def global_env=(env)
16
+ @@global_env = env
17
+ end
18
+
19
+ def global_env
20
+ @@global_env
21
+ end
22
+
15
23
  # Gets/sets app environment for tests
16
24
  attr_accessor :env
17
25
  end
@@ -22,15 +30,16 @@ module Syntropy
22
30
  #
23
31
  # @return [Hash] test app environment
24
32
  def env
25
- self.class.env
33
+ klass = self.class
34
+ klass.env || klass.global_env
26
35
  end
27
36
 
28
37
  # Loads and returns a module with the given reference.
29
38
  #
30
39
  # @param ref [String] module reference
31
40
  # @return [any] module
32
- def load_module(ref, raise_on_missing: true)
33
- app.module_loader.load(ref, raise_on_missing:)
41
+ def load_module(ref, raise_on_missing_export: true)
42
+ app.module_loader.load(ref, raise_on_missing_export:)
34
43
  end
35
44
 
36
45
  # Makes an HTTP request to the test app.
@@ -122,7 +131,7 @@ module Syntropy
122
131
  #
123
132
  # @return [void]
124
133
  def setup
125
- env = self.class.env
134
+ env = self.env
126
135
  raise 'Environment not set' if !env
127
136
 
128
137
  Syntropy.load_config(env)
@@ -136,7 +145,7 @@ module Syntropy
136
145
  )
137
146
  @test_harness = Syntropy::TestHarness.new(@app)
138
147
 
139
- @db = load_module('/_lib/storage', raise_on_missing: false)
148
+ @db = load_module('/_lib/storage', raise_on_missing_export: false)
140
149
  @db&.migrate! if @db.respond_to?(:migrate!)
141
150
  end
142
151
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syntropy
4
- VERSION = '0.38.1'
4
+ VERSION = '0.40.0'
5
5
  end
data/lib/syntropy.rb CHANGED
@@ -72,15 +72,22 @@ module Syntropy
72
72
  end
73
73
  end
74
74
 
75
+ # Loads the app config module based on the environment's config root and
76
+ # mode: (test, dev, production). The module's exported value is assigned to
77
+ # env[:config].
78
+ #
79
+ # @param env [Hash] app environment
80
+ # @return [void]
75
81
  def load_config(env)
76
82
  return if !env[:config_root]
77
83
 
78
84
  loader_env = env.merge(
79
85
  app_root: env[:config_root],
80
- logger: nil
86
+ logger: nil,
87
+ machine: UM.new(size: 8)
81
88
  )
82
89
  loader = ModuleLoader.new(loader_env)
83
- if (config = loader.load(env[:mode], raise_on_missing: false))
90
+ if (config = loader.load(env[:mode], raise_on_missing_export: false))
84
91
  env[:config] = config
85
92
  end
86
93
  end
data/syntropy.gemspec CHANGED
@@ -21,9 +21,9 @@ Gem::Specification.new do |s|
21
21
  s.required_ruby_version = '>= 3.4'
22
22
  s.executables = ['syntropy']
23
23
 
24
- s.add_dependency 'extralite', '~>2.14'
25
- s.add_dependency 'papercraft', '~>3.2.0'
26
- s.add_dependency 'uringmachine', '~>1.0.2'
24
+ s.add_dependency 'extralite', '~>3.0.1'
25
+ s.add_dependency 'papercraft', '~>3.2.2'
26
+ s.add_dependency 'uringmachine', '~>1.0.4'
27
27
 
28
28
  s.add_dependency 'json'
29
29
  s.add_dependency 'base64'
@@ -31,6 +31,6 @@ Gem::Specification.new do |s|
31
31
  s.add_dependency 'irb'
32
32
  s.add_dependency 'minitest'
33
33
 
34
- s.add_development_dependency 'rake', '~>13.3.1'
34
+ s.add_development_dependency 'rake'
35
35
  s.add_development_dependency 'solargraph'
36
36
  end
@@ -3,6 +3,6 @@ export template { |**props|
3
3
  h1 'Foo'
4
4
  }
5
5
  content {
6
- render_yield(**props)
6
+ render_children(**props)
7
7
  }
8
8
  }
@@ -0,0 +1,8 @@
1
+ export template { |**props|
2
+ header {
3
+ h1 'Kuku'
4
+ }
5
+ content {
6
+ render_children(**props)
7
+ }
8
+ }
@@ -0,0 +1,2 @@
1
+ export self
2
+ B = import './b'
@@ -0,0 +1,2 @@
1
+ export self
2
+ C = import './c'
@@ -0,0 +1,2 @@
1
+ export self
2
+ A = import './a'
@@ -0,0 +1,9 @@
1
+ export self
2
+
3
+ @machine.sleep(rand(0.001..0.01))
4
+
5
+ @env[:concurrent_counter][:count] += 1
6
+
7
+ def id
8
+ object_id
9
+ end
data/test/test_app.rb CHANGED
@@ -100,7 +100,7 @@ class AppTest < Minitest::Test
100
100
  assert_equal 'About', req.response_body.chomp
101
101
 
102
102
  req = @test_harness.request(':method' => 'GET', ':path' => '/test/about/foo')
103
- assert_equal '<!DOCTYPE html><html><head><title></title></head><body><p>Hello from Markdown</p></body></html>', req.response_body.gsub(/\n/, '')
103
+ assert_equal '<!DOCTYPE html><html><head></head><body><p>Hello from Markdown</p></body></html>', req.response_body.gsub(/\n/, '')
104
104
 
105
105
  req = @test_harness.request(':method' => 'HEAD', ':path' => '/test/about/foo')
106
106
  assert_nil req.response_body
data/test/test_caching.rb CHANGED
@@ -75,7 +75,7 @@ class CachingTest < Minitest::Test
75
75
  @adapter.serve_request
76
76
  response = read_client_side
77
77
 
78
- expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=3600\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
78
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=604800\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
79
79
  assert_equal expected, response
80
80
  end
81
81
 
@@ -92,7 +92,7 @@ class CachingTest < Minitest::Test
92
92
  @adapter.serve_request
93
93
  response = read_client_side
94
94
 
95
- expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=3600\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
95
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=604800\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
96
96
  assert_equal expected, response
97
97
 
98
98
  # good etag
@@ -118,7 +118,7 @@ class CachingTest < Minitest::Test
118
118
  @adapter.serve_request
119
119
  response = read_client_side
120
120
 
121
- expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=3600\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
121
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=604800\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
122
122
  assert_equal expected, response
123
123
 
124
124
  # good etag