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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/TODO.md +0 -39
- data/cmd/console.rb +18 -7
- data/cmd/serve.rb +26 -20
- data/cmd/test.rb +90 -21
- data/examples/blog/.gitignore +1 -0
- data/examples/blog/app/_lib/database.rb +13 -0
- data/examples/blog/app/_lib/{post_store.rb → posts.rb} +3 -1
- data/examples/blog/app/posts/[id]/edit.rb +2 -2
- data/examples/blog/app/posts/[id]/index.rb +8 -5
- data/examples/blog/app/posts/index.rb +7 -5
- data/examples/blog/app/posts/new.rb +1 -1
- data/examples/blog/config/development.rb +5 -0
- data/examples/blog/config/production.rb +4 -0
- data/examples/blog/config/test.rb +5 -0
- data/examples/blog/test/test_posts.rb +65 -0
- data/examples/mcp-oauth/app/oauth/token.rb +1 -1
- data/examples/mcp-oauth/test/test_app.rb +2 -20
- data/examples/mcp-oauth/test/test_oauth.rb +93 -217
- data/lib/syntropy/app.rb +48 -40
- data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +1 -1
- data/lib/syntropy/db/schema.rb +1 -1
- data/lib/syntropy/db/store.rb +2 -0
- data/lib/syntropy/errors.rb +6 -2
- data/lib/syntropy/http/client.rb +1 -0
- data/lib/syntropy/http/server_connection.rb +15 -13
- data/lib/syntropy/json_api.rb +27 -1
- data/lib/syntropy/logger.rb +81 -27
- data/lib/syntropy/markdown.rb +61 -32
- data/lib/syntropy/mime_types.rb +9 -5
- data/lib/syntropy/module_loader.rb +25 -13
- data/lib/syntropy/papercraft_extensions.rb +2 -2
- data/lib/syntropy/request/mock_adapter.rb +10 -8
- data/lib/syntropy/request/request_info.rb +91 -0
- data/lib/syntropy/request/response.rb +3 -14
- data/lib/syntropy/request/validation.rb +1 -0
- data/lib/syntropy/request.rb +55 -14
- data/lib/syntropy/routing_tree.rb +27 -28
- data/lib/syntropy/session.rb +198 -0
- data/lib/syntropy/side_run.rb +25 -2
- data/lib/syntropy/test.rb +168 -2
- data/lib/syntropy/utils.rb +53 -18
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +44 -10
- data/syntropy.gemspec +1 -0
- data/test/bm_router_proc.rb +4 -4
- data/test/fixtures/app/class_instance.rb +5 -0
- data/test/fixtures/app/http.rb +5 -0
- data/test/fixtures/app/post_ct.rb +5 -0
- data/test/fixtures/app/singleton.rb +3 -0
- data/test/test_app.rb +13 -52
- data/test/test_caching.rb +2 -2
- data/test/test_db_schema.rb +1 -1
- data/test/test_http_server_connection.rb +11 -8
- data/test/test_module_loader.rb +5 -2
- data/test/test_request_session.rb +254 -0
- data/test/test_response.rb +0 -19
- data/test/test_routing_tree.rb +69 -69
- data/test/test_server.rb +5 -9
- data/test/test_test.rb +70 -0
- metadata +67 -42
- data/examples/blog/app/_setup.rb +0 -4
- data/examples/mcp-oauth/test/helper.rb +0 -9
- /data/test/{app → fixtures/app}/.well-known/foo.rb +0 -0
- /data/test/{app → fixtures/app}/_hook.rb +0 -0
- /data/test/{app → fixtures/app}/_layout/default.rb +0 -0
- /data/test/{app → fixtures/app}/_lib/callable.rb +0 -0
- /data/test/{app → fixtures/app}/_lib/dep.rb +0 -0
- /data/test/{app → fixtures/app}/_lib/env.rb +0 -0
- /data/test/{app → fixtures/app}/_lib/klass.rb +0 -0
- /data/test/{app → fixtures/app}/_lib/missing-export.rb +0 -0
- /data/test/{app → fixtures/app}/_lib/self.rb +0 -0
- /data/test/{app → fixtures/app}/about/_error.rb +0 -0
- /data/test/{app → fixtures/app}/about/foo.md +0 -0
- /data/test/{app → fixtures/app}/about/index.rb +0 -0
- /data/test/{app → fixtures/app}/about/raise.rb +0 -0
- /data/test/{app → fixtures/app}/api+.rb +0 -0
- /data/test/{app → fixtures/app}/assets/style.css +0 -0
- /data/test/{app → fixtures/app}/bad_mod.rb +0 -0
- /data/test/{app → fixtures/app}/bar.rb +0 -0
- /data/test/{app → fixtures/app}/baz.rb +0 -0
- /data/test/{app → fixtures/app}/by_method.rb +0 -0
- /data/test/{app → fixtures/app}/deps.rb +0 -0
- /data/test/{app → fixtures/app}/index.html +0 -0
- /data/test/{app → fixtures/app}/mod/bar/index+.rb +0 -0
- /data/test/{app → fixtures/app}/mod/foo/index.rb +0 -0
- /data/test/{app → fixtures/app}/mod/path/a.rb +0 -0
- /data/test/{app → fixtures/app}/mod/path/b.rb +0 -0
- /data/test/{app → fixtures/app}/params/[foo].rb +0 -0
- /data/test/{app → fixtures/app}/rss.rb +0 -0
- /data/test/{app → fixtures/app}/tmp.rb +0 -0
- /data/test/{app_custom → fixtures/app_custom}/_site.rb +0 -0
- /data/test/{app_multi_site → fixtures/app_multi_site}/_site.rb +0 -0
- /data/test/{app_multi_site → fixtures/app_multi_site}/bar.baz/index.html +0 -0
- /data/test/{app_multi_site → fixtures/app_multi_site}/foo.bar/index.html +0 -0
- /data/test/{app_setup → fixtures/app_setup}/_setup.rb +0 -0
- /data/test/{app_setup → fixtures/app_setup}/index.rb +0 -0
- /data/test/{app_with_schema → fixtures/app_with_schema}/_schema/2026-01-02-foo.rb +0 -0
- /data/test/{app_with_schema → fixtures/app_with_schema}/_schema/2026-05-30-bar.rb +0 -0
- /data/test/{schema → fixtures/schema}/2026-01-02-foo.rb +0 -0
- /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
|
data/lib/syntropy/side_run.rb
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/syntropy/utils.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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[:
|
|
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.
|
|
42
|
+
atts, markdown = Syntropy::Markdown.parse(it, env)
|
|
33
43
|
{ atts:, markdown: }
|
|
34
44
|
}
|
|
35
45
|
end
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/syntropy/version.rb
CHANGED
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
|
-
|
|
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]
|
|
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
|
|
62
|
-
return
|
|
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
|
-
|
|
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
|
-
#
|
|
77
|
-
#
|
|
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)
|