syntropy 0.32.0 → 0.34.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/TODO.md +0 -39
  4. data/cmd/console.rb +18 -7
  5. data/cmd/serve.rb +26 -20
  6. data/cmd/test.rb +90 -21
  7. data/examples/blog/.gitignore +1 -0
  8. data/examples/blog/app/_lib/database.rb +13 -0
  9. data/examples/blog/app/_lib/{post_store.rb → posts.rb} +3 -1
  10. data/examples/blog/app/posts/[id]/edit.rb +2 -2
  11. data/examples/blog/app/posts/[id]/index.rb +8 -5
  12. data/examples/blog/app/posts/index.rb +7 -5
  13. data/examples/blog/app/posts/new.rb +1 -1
  14. data/examples/blog/config/development.rb +5 -0
  15. data/examples/blog/config/production.rb +4 -0
  16. data/examples/blog/config/test.rb +5 -0
  17. data/examples/blog/test/test_posts.rb +65 -0
  18. data/examples/mcp-oauth/app/oauth/token.rb +1 -1
  19. data/examples/mcp-oauth/test/test_app.rb +2 -20
  20. data/examples/mcp-oauth/test/test_oauth.rb +93 -217
  21. data/lib/syntropy/app.rb +48 -40
  22. data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +1 -1
  23. data/lib/syntropy/db/schema.rb +1 -1
  24. data/lib/syntropy/db/store.rb +2 -0
  25. data/lib/syntropy/errors.rb +6 -2
  26. data/lib/syntropy/http/client.rb +1 -0
  27. data/lib/syntropy/http/server_connection.rb +15 -13
  28. data/lib/syntropy/json_api.rb +27 -1
  29. data/lib/syntropy/logger.rb +81 -27
  30. data/lib/syntropy/markdown.rb +61 -32
  31. data/lib/syntropy/mime_types.rb +9 -5
  32. data/lib/syntropy/module_loader.rb +25 -13
  33. data/lib/syntropy/papercraft_extensions.rb +2 -2
  34. data/lib/syntropy/request/mock_adapter.rb +10 -8
  35. data/lib/syntropy/request/request_info.rb +91 -0
  36. data/lib/syntropy/request/response.rb +3 -14
  37. data/lib/syntropy/request/validation.rb +1 -0
  38. data/lib/syntropy/request.rb +55 -14
  39. data/lib/syntropy/routing_tree.rb +27 -28
  40. data/lib/syntropy/session.rb +198 -0
  41. data/lib/syntropy/side_run.rb +25 -2
  42. data/lib/syntropy/test.rb +168 -2
  43. data/lib/syntropy/utils.rb +53 -18
  44. data/lib/syntropy/version.rb +1 -1
  45. data/lib/syntropy.rb +44 -10
  46. data/syntropy.gemspec +1 -0
  47. data/test/bm_router_proc.rb +4 -4
  48. data/test/fixtures/app/class_instance.rb +5 -0
  49. data/test/fixtures/app/http.rb +5 -0
  50. data/test/fixtures/app/post_ct.rb +5 -0
  51. data/test/fixtures/app/singleton.rb +3 -0
  52. data/test/test_app.rb +13 -52
  53. data/test/test_caching.rb +2 -2
  54. data/test/test_db_schema.rb +1 -1
  55. data/test/test_http_server_connection.rb +11 -8
  56. data/test/test_module_loader.rb +5 -2
  57. data/test/test_request_session.rb +254 -0
  58. data/test/test_response.rb +0 -19
  59. data/test/test_routing_tree.rb +69 -69
  60. data/test/test_server.rb +5 -9
  61. data/test/test_test.rb +70 -0
  62. metadata +67 -42
  63. data/examples/blog/app/_setup.rb +0 -4
  64. data/examples/mcp-oauth/test/helper.rb +0 -9
  65. /data/test/{app → fixtures/app}/.well-known/foo.rb +0 -0
  66. /data/test/{app → fixtures/app}/_hook.rb +0 -0
  67. /data/test/{app → fixtures/app}/_layout/default.rb +0 -0
  68. /data/test/{app → fixtures/app}/_lib/callable.rb +0 -0
  69. /data/test/{app → fixtures/app}/_lib/dep.rb +0 -0
  70. /data/test/{app → fixtures/app}/_lib/env.rb +0 -0
  71. /data/test/{app → fixtures/app}/_lib/klass.rb +0 -0
  72. /data/test/{app → fixtures/app}/_lib/missing-export.rb +0 -0
  73. /data/test/{app → fixtures/app}/_lib/self.rb +0 -0
  74. /data/test/{app → fixtures/app}/about/_error.rb +0 -0
  75. /data/test/{app → fixtures/app}/about/foo.md +0 -0
  76. /data/test/{app → fixtures/app}/about/index.rb +0 -0
  77. /data/test/{app → fixtures/app}/about/raise.rb +0 -0
  78. /data/test/{app → fixtures/app}/api+.rb +0 -0
  79. /data/test/{app → fixtures/app}/assets/style.css +0 -0
  80. /data/test/{app → fixtures/app}/bad_mod.rb +0 -0
  81. /data/test/{app → fixtures/app}/bar.rb +0 -0
  82. /data/test/{app → fixtures/app}/baz.rb +0 -0
  83. /data/test/{app → fixtures/app}/by_method.rb +0 -0
  84. /data/test/{app → fixtures/app}/deps.rb +0 -0
  85. /data/test/{app → fixtures/app}/index.html +0 -0
  86. /data/test/{app → fixtures/app}/mod/bar/index+.rb +0 -0
  87. /data/test/{app → fixtures/app}/mod/foo/index.rb +0 -0
  88. /data/test/{app → fixtures/app}/mod/path/a.rb +0 -0
  89. /data/test/{app → fixtures/app}/mod/path/b.rb +0 -0
  90. /data/test/{app → fixtures/app}/params/[foo].rb +0 -0
  91. /data/test/{app → fixtures/app}/rss.rb +0 -0
  92. /data/test/{app → fixtures/app}/tmp.rb +0 -0
  93. /data/test/{app_custom → fixtures/app_custom}/_site.rb +0 -0
  94. /data/test/{app_multi_site → fixtures/app_multi_site}/_site.rb +0 -0
  95. /data/test/{app_multi_site → fixtures/app_multi_site}/bar.baz/index.html +0 -0
  96. /data/test/{app_multi_site → fixtures/app_multi_site}/foo.bar/index.html +0 -0
  97. /data/test/{app_setup → fixtures/app_setup}/_setup.rb +0 -0
  98. /data/test/{app_setup → fixtures/app_setup}/index.rb +0 -0
  99. /data/test/{app_with_schema → fixtures/app_with_schema}/_schema/2026-01-02-foo.rb +0 -0
  100. /data/test/{app_with_schema → fixtures/app_with_schema}/_schema/2026-05-30-bar.rb +0 -0
  101. /data/test/{schema → fixtures/schema}/2026-01-02-foo.rb +0 -0
  102. /data/test/{schema → fixtures/schema}/2026-05-30-bar.rb +0 -0
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require 'securerandom'
6
+
7
+ module Syntropy
8
+ # A Session object serves as storage for data associated with the user's
9
+ # browser session, such as flash data (for communicating notices and alerts).
10
+ # The session is modeled as a key-value store, where keys are strings, and
11
+ # values can be any value that can be represented in JSON, including arrays
12
+ # and hashes.
13
+ #
14
+ # The session data is stored as an HTTP cookie.
15
+ class Session
16
+ # Initializes the session.
17
+ #
18
+ # @param request [Syntropy::Request] associated request
19
+ # @return [void]
20
+ def initialize(request)
21
+ @request = request
22
+ @data = nil
23
+ end
24
+
25
+ # Returns the value associated with the given key.
26
+ #
27
+ # @param key [String]
28
+ # @return [any] value
29
+ def [](key)
30
+ @data ||= load
31
+ @data[key]
32
+ end
33
+
34
+ # Sets the value for the given key and updates the response session cookie.
35
+ #
36
+ # @param key [String]
37
+ # @param value [any]
38
+ # @return [void]
39
+ def []=(key, value)
40
+ @data ||= load
41
+ @data[key] = value
42
+ save(@data)
43
+ end
44
+
45
+ # Deletes the given key-value pair and updates the response session cookie.
46
+ #
47
+ # @param key [String]
48
+ # @return [any] deleted value
49
+ def delete(key)
50
+ @data ||= load
51
+ value = @data.delete(key)
52
+ save(@data.empty? ? nil : @data)
53
+ value
54
+ end
55
+
56
+ # Discards the session data, updating the response session cookie by
57
+ # emptying it.
58
+ #
59
+ # @return [void]
60
+ def discard
61
+ save(nil)
62
+ end
63
+
64
+ # Returns the flash storage for the session.
65
+ #
66
+ # @return [Syntropy::Session::Flash]
67
+ def flash
68
+ @data ||= load
69
+ @flash ||= Flash.new(self)
70
+ end
71
+
72
+ private
73
+
74
+ # Loads session data from the request session cookie.
75
+ #
76
+ # @return [Hash] session data
77
+ def load
78
+ data = @request.cookies['__syntropy_session__']
79
+ return {} if !data
80
+
81
+ JSON.parse(Base64.decode64(data))
82
+ rescue JSON::ParserError
83
+ {}
84
+ ensure
85
+ @loaded = true
86
+ end
87
+
88
+ # Saves session data to the response session cookie.
89
+ #
90
+ # @param data [Hash] session data
91
+ # @return [void]
92
+ def save(data)
93
+ cookie = data ? "#{Base64.strict_encode64(JSON.dump(data))}; Path=/; HttpOnly" : nil
94
+ @request.set_cookie('__syntropy_session__', cookie)
95
+ end
96
+ end
97
+
98
+ # NowFlash holds flash data for the current request.
99
+ class NowFlash
100
+ def initialize
101
+ @data = {}
102
+ end
103
+
104
+ # Returns the value for the given key.
105
+ #
106
+ # @param key [Symbol]
107
+ # @return [any] flash value
108
+ def [](key)
109
+ @data[key.to_s]
110
+ end
111
+
112
+ # Sets the value for the given key.
113
+ #
114
+ # @param key [Symbol]
115
+ # @param value [any]
116
+ # @return [any] value
117
+ def []=(key, value)
118
+ @data[key.to_s] = value
119
+ end
120
+
121
+ # Iterates through the flash storage, yielding each key-value pair to the
122
+ # given block.
123
+ #
124
+ # @return [void]
125
+ def each(&block)
126
+ @data.each { |k, v| block.(k.to_sym, v) }
127
+ end
128
+ end
129
+
130
+ # Flash acts as a special storage mechanism for transient information that
131
+ # can be passsed between consecutive requests in the same session. Flash
132
+ # values can be set in order to be retrieved in the next response. Reading
133
+ # from flash storage will return data that was set in the previous request.
134
+ # Data written to the flash storage will only be available to the next
135
+ # request. You can also set flash data that will be available to the current
136
+ # request by using Flash#now.
137
+ #
138
+ # In order to keep the read flash data (set in the previous request) and
139
+ # make it available to the next request, use Flash#keep.
140
+ class Flash
141
+ # Initializes the flash storage.
142
+ #
143
+ # @return [void]
144
+ def initialize(session)
145
+ @session = session
146
+ @current_flash_data = @session['_flash']
147
+ @session.delete('_flash') if @current_flash_data
148
+ @current_flash_data ||= {}
149
+ @future_flash_data = {}
150
+ @now_flash_data = NowFlash.new
151
+ end
152
+
153
+ # Reads data from flash storage for the given key. The value would have
154
+ # been set in the previous request.
155
+ #
156
+ # @param key [Symbol]
157
+ # @return [any] value
158
+ def [](key)
159
+ key = key.to_s
160
+ @now_flash_data[key] || @current_flash_data[key]
161
+ end
162
+
163
+ # Sets the flash storage value for the given key. The value would be
164
+ # available to the next request.
165
+ #
166
+ # @param key [Symbol]
167
+ # @param value [any]
168
+ # @return [void]
169
+ def []=(key, value)
170
+ key = key.to_s
171
+ @future_flash_data[key] = value
172
+ @session['_flash'] = @future_flash_data
173
+ end
174
+
175
+ # Iterates through the flash storage, passing each key-value pair to the
176
+ # given block.
177
+ #
178
+ # @return [void]
179
+ def each(&block)
180
+ @current_flash_data.each_pair { |k, v| block.(k.to_sym, v) }
181
+ end
182
+
183
+ # Persists any flash data set in the previous request to the next request.
184
+ #
185
+ # @return [void]
186
+ def keep
187
+ @future_flash_data = @current_flash_data.merge!(@future_flash_data)
188
+ @session['_flash'] = @future_flash_data
189
+ end
190
+
191
+ # Returns the flash storage for the current request.
192
+ #
193
+ # @return [Syntropy::NowFlash]
194
+ def now
195
+ @now_flash_data
196
+ end
197
+ end
198
+ end
@@ -3,8 +3,16 @@
3
3
  require 'etc'
4
4
 
5
5
  module Syntropy
6
+ # SideRun implements running an operation on a separate thread.
6
7
  module SideRun
7
8
  class << self
9
+ # Runs the given block on a separate thread, using UringMachine to wait
10
+ # for the operation to complete. If the operation results in a raised
11
+ # exception, that exception will be reraised in the context of the waiting
12
+ # fiber.
13
+ #
14
+ # @param machine [UringMachine] machine instance
15
+ # @return [any] operation return value
8
16
  def call(machine, &block)
9
17
  setup if !@queue
10
18
 
@@ -15,6 +23,11 @@ module Syntropy
15
23
  result.is_a?(Exception) ? (raise result) : result
16
24
  end
17
25
 
26
+ private
27
+
28
+ # Sets up a thread pool for side-running operations.
29
+ #
30
+ # @return [void]
18
31
  def setup
19
32
  @queue = UM::Queue.new
20
33
  count = (Etc.nprocessors - 1).clamp(2..6)
@@ -23,9 +36,13 @@ module Syntropy
23
36
  }
24
37
  end
25
38
 
39
+ # Runs worker loop for running side-run operations.
40
+ #
41
+ # @param queue [UringMachine::Queue] queue for pulling operations
42
+ # @return [void]
26
43
  def side_run_worker(queue)
27
44
  machine = UM.new
28
- loop { handle_request(machine, queue) }
45
+ loop { run_op(machine, queue) }
29
46
  rescue UM::Terminate
30
47
  # # We can also add a timeout here
31
48
  # t0 = Time.now
@@ -34,7 +51,13 @@ module Syntropy
34
51
  # end
35
52
  end
36
53
 
37
- def handle_request(machine, queue)
54
+ # Pulls an operation from the given queue and runs it, pushing its return
55
+ # value to the corresponding mailbox.
56
+ #
57
+ # @param machine [UringMachine] machine instance
58
+ # @param queue [UringMachine::Queue] op queue
59
+ # @return [void]
60
+ def run_op(machine, queue)
38
61
  response_mailbox, closure = machine.shift(queue)
39
62
  result = closure.call
40
63
  machine.push(response_mailbox, result)
data/lib/syntropy/test.rb CHANGED
@@ -3,20 +3,160 @@
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
+ # Test provides a class for testing a Syntropy app, based on Minitest.
11
+ class Test < Minitest::Test
12
+ HTTP = Syntropy::HTTP
13
+
14
+ # Sets the app environment for all Syntropy tests.
15
+ #
16
+ # @param env [Hash] app environment hash
17
+ # @return [void]
18
+ def self.env=(env)
19
+ @@env = env
20
+ end
21
+
22
+ attr_reader :machine, :app
23
+
24
+ # Returns the test environment.
25
+ #
26
+ # @return [Hash] test app environment
27
+ def env
28
+ @@env
29
+ end
30
+
31
+ # Loads and returns a module with the given reference.
32
+ #
33
+ # @param ref [String] module reference
34
+ # @return [any] module
35
+ def load_module(ref, raise_on_missing: true)
36
+ app.module_loader.load(ref, raise_on_missing:)
37
+ end
38
+
39
+ # Makes an HTTP request to the test app.
40
+ #
41
+ # @param headers [Hash] request headers
42
+ # @param body [String, nil] request body
43
+ # @return [Syntropy::Request]
44
+ def http_request(headers, body = nil)
45
+ @test_harness.request(headers, body)
46
+ end
47
+
48
+ # Makes an HTTP GET request to the test app.
49
+ #
50
+ # @param path [String] request path
51
+ # @param headers [Hash] request headers
52
+ # @return [Syntropy::Request]
53
+ def get(path, **headers)
54
+ http_request(
55
+ headers.merge(
56
+ ':method' => 'GET',
57
+ ':path' => path
58
+ )
59
+ )
60
+ end
61
+
62
+ # Makes an HTTP POST request to the test app.
63
+ #
64
+ # @param path [String] request path
65
+ # @param content_type [String, nil] content MIME type
66
+ # @param body [String] request body
67
+ # @param headers [Hash] request headers
68
+ # @return [Syntropy::Request]
69
+ def post(path, content_type, body, **headers)
70
+ headers = headers.merge('content-type' => content_type) if content_type
71
+ http_request(
72
+ headers.merge(
73
+ {
74
+ ':method' => 'POST',
75
+ ':path' => path
76
+ }
77
+ ),
78
+ body
79
+ )
80
+ end
81
+
82
+ # Makes an HTTP POST request to the test app with a "application/json"
83
+ # content type. The given object is converted to JSON and sent as the
84
+ # request body.
85
+ #
86
+ # @param path [String] request path
87
+ # @param data [any] data
88
+ # @return [Syntropy::Request]
89
+ def post_json(path, data, **)
90
+ post(path, 'application/json', JSON.dump(data), **)
91
+ end
92
+
93
+ # Makes an HTTP POST request to the test app with a
94
+ # "application/x-www-form-urlencoded" content type. The given data is
95
+ # converted to URL Encoded form format and sent as the request body.
96
+ #
97
+ # @param path [String] request path
98
+ # @param data [Hash] form data
99
+ # @return [Syntropy::Request]
100
+ def post_form(path, data, **)
101
+ post(path, 'application/x-www-form-urlencoded', URI.encode_www_form(data), **)
102
+ end
103
+
104
+ # Sets up a test instance.
105
+ #
106
+ # @return [void]
107
+ def setup
108
+ raise 'Environment not set' if !@@env
109
+
110
+ Syntropy.load_config(@@env)
111
+
112
+ @machine = UM.new
113
+ @app = Syntropy::App.new(
114
+ **@@env.merge(
115
+ machine: @machine,
116
+ test_mode: true
117
+ )
118
+ )
119
+ @test_harness = Syntropy::TestHarness.new(@app)
120
+
121
+ @db = load_module('/_lib/database', raise_on_missing: false)
122
+ @db&.migrate!
123
+ end
124
+
125
+ # Cleans up a test instance.
126
+ #
127
+ # @return [void]
128
+ def teardown
129
+ @machine = nil
130
+ @app = nil
131
+ @test_harness = nil
132
+ end
133
+ end
134
+
135
+ # TestHarness provides glue code for performing HTTP requests against a
136
+ # Syntropy app.
8
137
  class TestHarness
138
+ # Initializes the test harness with the given app.
139
+ #
140
+ # @param app [Syntropy::App]
141
+ # @return [void]
9
142
  def initialize(app)
10
143
  @app = app
11
144
  @app.raise_internal_server_error = true if @app.respond_to?(:raise_internal_server_error=)
12
145
  end
13
146
 
147
+ # Perfrms a request against the associated app.
148
+ #
149
+ # @param headers [Hash] request headers
150
+ # @param body [String, nil] request body
151
+ # @return [Syntropy::Request]
14
152
  def request(headers, body = nil)
15
153
  req = mock_req(headers, body)
16
154
  @app.call(req)
17
155
  req
18
156
  end
19
157
 
158
+ # Temporarily disables raising an exception in case of an internal server
159
+ # error while running the given block.
20
160
  def no_raise_internal_server_error
21
161
  return yield if !@app.respond_to?(:raise_internal_server_error=)
22
162
 
@@ -28,32 +168,52 @@ module Syntropy
28
168
  end
29
169
  end
30
170
 
31
-
32
171
  private
33
172
 
173
+ # Creates a Syntropy request running on a mock adapter.
174
+ #
175
+ # @param headers [Hash] request headers
176
+ # @param body [String, nil] request body
177
+ # @return [Syntropy::Request]
34
178
  def mock_req(headers, body = nil)
35
179
  Syntropy::MockAdapter.mock(headers, body)
36
180
  end
37
181
  end
38
182
 
39
- class Request
183
+ # Extensions to Syntropy::Request for testing.
184
+ module TestRequestExtensions
185
+ # Returns the response headers.
186
+ #
187
+ # @return [Hash]
40
188
  def response_headers
41
189
  adapter.response_headers
42
190
  end
43
191
 
192
+ # Returns the response status
193
+ #
194
+ # @return [Integer]
44
195
  def response_status
45
196
  adapter.status
46
197
  end
47
198
 
199
+ # Returns the response body
200
+ #
201
+ # @return [String, nil]
48
202
  def response_body
49
203
  adapter.response_body
50
204
  end
51
205
 
206
+ # Parses the response body from JSON.
207
+ #
208
+ # @return [any] parsed JSON object
52
209
  def response_json
53
210
  raise if response_content_type != 'application/json'
54
211
  JSON.parse(response_body)
55
212
  end
56
213
 
214
+ # Returns the response content MIME type.
215
+ #
216
+ # @return [String, nil]
57
217
  def response_content_type
58
218
  ct = response_headers['Content-Type']
59
219
  return nil if !ct
@@ -64,6 +224,10 @@ module Syntropy
64
224
  m[1]
65
225
  end
66
226
 
227
+ # Returns the cookie value for the given cookie name from the response.
228
+ #
229
+ # @param name [String, Symbol] cookie name
230
+ # @return [String, nil] cookie value
67
231
  def response_cookie(name)
68
232
  sc = response_headers['Set-Cookie']
69
233
  return nil if !sc
@@ -74,4 +238,6 @@ module Syntropy
74
238
  m[1]
75
239
  end
76
240
  end
241
+
242
+ Request.include TestRequestExtensions
77
243
  end
@@ -1,52 +1,87 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module Syntropy
4
6
  # Utilities for use in modules
5
7
  module Utilities
6
- # Returns a request handler that routes request according to
8
+ def tmp_path(prefix = 'syntropy')
9
+ "/tmp/#{prefix}-#{SecureRandom.hex(16)}"
10
+ end
11
+
12
+ # Returns a request handler that routes request according to the host
13
+ # header. Looks for site directories (named by host name) in the app's root
14
+ # directory. A map may be given in order to provide additional hostnames to
15
+ # site directories.
16
+ #
17
+ # @param env [Hash] app environment hash
18
+ # @param map [Hash, nil] additional hostname map
19
+ # @return [Proc] router proc
7
20
  def route_by_host(env, map = nil)
8
- root = env[:root_dir]
9
- sites = Dir[File.join(root, '*')]
10
- .reject { File.basename(it) =~ /^_/ }
11
- .select { File.directory?(it) }
12
- .each_with_object({}) { |fn, h|
13
- name = File.basename(fn)
14
- h[name] = Syntropy::App.new(**env.merge(root_dir: fn))
15
- }
16
-
17
- # copy over map refs
21
+ sites = find_hostname_sites(env)
22
+
23
+ # add map refs
18
24
  map&.each { |k, v| sites[k] = sites[v] }
19
25
 
20
- #
21
26
  lambda { |req|
22
27
  site = sites[req.host]
23
28
  site ? site.call(req) : req.respond(nil, ':status' => HTTP::BAD_REQUEST)
24
29
  }
25
30
  end
26
31
 
32
+ # Returns a list of parsed markdown pages at the given path.
33
+ #
34
+ # @param env [Hash] app environment hash
35
+ # @param ref [String] directory path
36
+ # @return [Array<Hash>] array of page entries
27
37
  def page_list(env, ref)
28
- full_path = File.join(env[:root_dir], ref)
38
+ full_path = File.join(env[:app_root], ref)
29
39
  raise 'Not a directory' if !File.directory?(full_path)
30
40
 
31
41
  Dir[File.join(full_path, '*.md')].sort.map {
32
- atts, markdown = Syntropy.parse_markdown_file(it, env)
42
+ atts, markdown = Syntropy::Markdown.parse(it, env)
33
43
  { atts:, markdown: }
34
44
  }
35
45
  end
36
46
 
37
- def app(**env)
38
- Syntropy::App.new(**env)
47
+ # Instantiates a Syntropy app for the given environment hash.
48
+ #
49
+ # @return [Syntropy::App]
50
+ def app(**)
51
+ Syntropy::App.new(**)
39
52
  end
40
53
 
41
- BUILTIN_APPLET_ROOT_DIR = File.expand_path(File.join(__dir__, 'applets/builtin'))
54
+ BUILTIN_APPLET_app_root = File.expand_path(File.join(__dir__, 'applets/builtin'))
55
+
56
+ # Creates a builtin applet with the given environment hash. By default the
57
+ # builtin applet is mounted at /.syntropy.
58
+ #
59
+ # @param env [Hash] app environment
60
+ # @param mount_path [String] mount path for the builtin applet
61
+ # @return [Syntropy::App] applet
42
62
  def builtin_applet(env, mount_path: '/.syntropy')
43
63
  app(
44
64
  machine: env[:machine],
45
- root_dir: BUILTIN_APPLET_ROOT_DIR,
65
+ app_root: BUILTIN_APPLET_app_root,
46
66
  mount_path: mount_path,
47
67
  builtin_applet_path: nil,
48
68
  watch_files: nil
49
69
  )
50
70
  end
71
+
72
+ private
73
+
74
+ # Finds sites in the root directory for the given environment hash.
75
+ #
76
+ # @param env [Hash] app environment hash
77
+ # @return [Hash] hash mapping hostname to app
78
+ def find_hostname_sites(env)
79
+ Dir[File.join(env[:app_root], '*')]
80
+ .select { File.directory?(it) && File.basename(it) !~ /^_/ }
81
+ .each_with_object({}) { |fn, h|
82
+ name = File.basename(fn)
83
+ h[name] = Syntropy::App.new(**env.merge(app_root: fn))
84
+ }
85
+ end
51
86
  end
52
87
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syntropy
4
- VERSION = '0.32.0'
4
+ VERSION = '0.34.0'
5
5
  end
data/lib/syntropy.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'uringmachine'
4
4
  require 'papercraft'
5
+ require 'yaml'
5
6
 
6
7
  require 'syntropy/request'
7
8
  require 'syntropy/logger'
@@ -21,20 +22,31 @@ require 'syntropy/side_run'
21
22
  require 'syntropy/utils'
22
23
  require 'syntropy/version'
23
24
 
25
+ # Syntropy is a web framework for building web apps in Ruby. Syntropy uses
26
+ # UringMachine for I/O and concurrency, and provides a comprehensive and
27
+ # flexible solution for writing web apps with minimal boilerplate.
24
28
  module Syntropy
25
29
  extend Utilities
26
30
 
27
31
  class << self
28
- attr_accessor :machine
32
+ attr_accessor :machine, :dev_mode
29
33
 
34
+ # Runs the given block on a separate thread. Use this method for running
35
+ # code that is not fiber-aware (i.e. does not use UringMachine).
36
+ #
37
+ # @return [any] operation return value
30
38
  def side_run(&block)
31
39
  raise 'Syntropy.machine not set' if !@machine
32
40
 
33
41
  SideRun.call(@machine, &block)
34
42
  end
35
- end
36
43
 
37
- class << self
44
+ # Runs a web app with the given environment hash. The given block is either
45
+ # an instance of Syntropy::App, or a Proc/callable that takes a request as
46
+ # argument.
47
+ #
48
+ # @param env [Hash] environment
49
+ # @return [void]
38
50
  def run(env = {}, &app)
39
51
  if @in_run
40
52
  @env = env
@@ -47,7 +59,14 @@ module Syntropy
47
59
  @in_run = true
48
60
  machine = env[:machine] || UM.new
49
61
 
50
- env[:logger]&.info(message: "Running Syntropy #{Syntropy::VERSION}, UringMachine #{UM::VERSION}, Ruby #{RUBY_VERSION}")
62
+ if (logger = env[:logger])
63
+ logger.info(
64
+ message: "Syntropy #{Syntropy::VERSION}, UringMachine #{UM::VERSION}, Ruby #{RUBY_VERSION}"
65
+ )
66
+ logger.info(
67
+ message: "Running in #{env[:mode]} mode"
68
+ )
69
+ end
51
70
 
52
71
  server = HTTP::Server.new(machine, env, &app)
53
72
 
@@ -58,23 +77,38 @@ module Syntropy
58
77
  end
59
78
  end
60
79
 
61
- def env(env = nil, &app)
62
- return @env if !env && !app
80
+ def load_config(env)
81
+ return if !env[:config_root]
82
+
83
+ loader_env = env.merge(
84
+ app_root: env[:config_root],
85
+ logger: nil
86
+ )
87
+ loader = ModuleLoader.new(loader_env)
88
+ config = loader.load(env[:mode])
63
89
 
64
- @env = env || {}
65
- @env[:app] = app if app
90
+ env[:config] = config
66
91
  end
67
92
 
68
93
  private
69
94
 
95
+ # Sets up asynchronous SIGINT handling.
96
+ #
97
+ # @param machine [UringMachine] machine instance
98
+ # @param fiber [Fiber] fiber to terminate on SIGINT
99
+ # @return [void]
70
100
  def setup_signal_handling(machine, fiber)
71
101
  queue = UM::Queue.new
72
102
  trap('SIGINT') { machine.push(queue, :SIGINT) }
73
103
  machine.spin { watch_for_int_signal(machine, queue, fiber) }
74
104
  end
75
105
 
76
- # waits for signal from queue, then terminates given fiber
77
- # to be done
106
+ # Waits for signal from queue, then terminates the given fiber.
107
+ #
108
+ # @param machine [UringMachine] machine instance
109
+ # @param queue [UringMachine::Queue] queue to wait on
110
+ # @param fiber [Fiber] fiber to terminate
111
+ # @return [void]
78
112
  def watch_for_int_signal(machine, queue, fiber)
79
113
  machine.shift(queue)
80
114
  machine.schedule(fiber, UM::Terminate.new)