syntropy 0.6 → 0.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 553553edc3b0c81902bc3a77470bc6e9789fcd757066dcd5b4e790d38f7a28bf
4
- data.tar.gz: fdb2cec5f581ba49d5156cce188f31a4811d29767c71833bfcfa37899ee0efc5
3
+ metadata.gz: b86a65bfeb990aa38b76b5bb6487210887e7103bab652e40c3790d68c1ab48df
4
+ data.tar.gz: 89397a9faf63d2a07c69ab69f93048cb92c82014e30d7ca6ed9399671c17bb48
5
5
  SHA512:
6
- metadata.gz: 7340ecb01725dcc15a78a66fef5f87a113c2e9759a2305753241b0c1cf738f45b3fd6fea2dcf7bca55196cc3fc309db2ade3e7bb12292fc5aa3abe63362403f9
7
- data.tar.gz: 48427e653e2c99022177e5475ef53b66c57143288f43e4997c40f1ba464728b310c5e0df343e7f03e3ed79f9fa8637e305b6a22296b19fb15068b7228cf0531f
6
+ metadata.gz: 0f536c81be4baa5c8e8733705a9a41042b322eb3960ee8a092523bfdfff886893a4183a6b00c69c2960daefe6443a6f06ee0e07596eeb5ffc3b45ceceb86c342
7
+ data.tar.gz: 05c3e6c5d3641847a0c962811e290313f7d08a4233504466035e94cfc03284cf652f990f5fd4681efd059673050fda5d021bba5a982c0e539b8c3351bd316913
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 0.7 2025-07-05
2
+
3
+ - Implement `Module.route_by_host`
4
+ - Add snoozing on DB progress
5
+
1
6
  ## 0.6 2025-07-05
2
7
 
3
8
  - Add support for middleware and error handlers
data/README.md CHANGED
@@ -59,6 +59,7 @@ site/
59
59
  ├ _articles/
60
60
  | └ 2025-01-01-hello_world.md
61
61
  ├ api/
62
+ | ├ _hook.rb
62
63
  | └ v1.rb
63
64
  ├ assets/
64
65
  | ├ css/
@@ -73,10 +74,30 @@ site/
73
74
  Syntropy knows how to serve static asset files (CSS, JS, images...) as well as
74
75
  render markdown files and run modules written in Ruby.
75
76
 
77
+ Some conventions employed in Syntropy-based web apps:
78
+
79
+ - Files and directories starting with an underscore, e.g. `/_layout` are
80
+ considered private, and are not exposed to HTTP clients.
81
+ - Normally, a module route only responds to its exact path. To respond to any
82
+ subtree path, add a plus sign to the end of the module name, e.g. `/api+.rb`.
83
+ - A `_hook.rb` module is invoked on each request routed to anywhere in the
84
+ corresponding subtree. For example, a hook defined in `/api/_hook.rb` will be
85
+ used on requests to `/api`, `/api/foo`, `/api/bar` etc.
86
+ - As a corollary, each route "inherits" all hooks defined up the tree. For
87
+ example, a request to `/api/foo` will invoke hooks defined in `/api/_hook.rb`
88
+ and `/_hook.rb`.
89
+ - In a similar fashion to hooks, error handlers can be defined for different
90
+ subtrees in a `_error.rb` module. For each route, in case of an exception,
91
+ Syntropy will invoke the closest-found error handler module up the tree. For
92
+ example, an error raised while responding to a request to `/api/foo` will
93
+ prefer the error handler in `/api/_error.rb`, rather than `/_error.rb`.
94
+ - The Syntrpy router accepts clean URLs for Ruby modules and Markdown files. It
95
+ also accepts clean URLs for `index.html` files.
96
+
76
97
  ## What does a Syntropic Ruby module look like?
77
98
 
78
- Consider `archive.rb` in the example above. We want to get a list of articles
79
- and render it with the given layout:
99
+ Consider `site/archive.rb` in the file tree above. We want to get a list of
100
+ articles and render it using the given layout:
80
101
 
81
102
  ```ruby
82
103
  # archive.rb
@@ -97,7 +118,7 @@ export @@layout.apply(title: 'archive') {
97
118
  }
98
119
  ```
99
120
 
100
- But a module can be something completely different:
121
+ But a module can also be something completely different:
101
122
 
102
123
  ```ruby
103
124
  # api/v1.rb
@@ -121,4 +142,114 @@ export APIV1
121
142
  ```
122
143
 
123
144
  Basically, the exported value can be a template, a callable or a class that
124
- responds to the request.
145
+ responds to the request. Here's a minimal module that responds with a hello
146
+ world:
147
+
148
+ ```ruby
149
+ export ->(req) { req.respond('Hello, world!') }
150
+ ```
151
+
152
+ ## Module Export / Import
153
+
154
+ Modules communicate with the Syntropy framework and with other modules using
155
+ `export` and `import`. Each module must export a single object, which can be a
156
+ controller class, a callable (a proc/closure) or a template. The exported object
157
+ is used by Syntropy as the entrypoint for the route.
158
+
159
+ But modules can also import other modules. This permits the use of layouts:
160
+
161
+ ```ruby
162
+ # site/_layout/default.rb
163
+ export template { |**props|
164
+ header {
165
+ h1 'Foo'
166
+ }
167
+ content {
168
+ emit_yield(**props)
169
+ }
170
+ }
171
+
172
+ # site/index.rb
173
+ layout = import '_layout/default'
174
+
175
+ export layout.apply { |**props|
176
+ p 'o hi!'
177
+ }
178
+ ```
179
+
180
+ A module can also be written as a set of methods without any explicit class
181
+ definition. This allows writing modules in a more functional style:
182
+
183
+ ```ruby
184
+ # site/_lib/utils.rb
185
+
186
+ def foo
187
+ 42
188
+ end
189
+
190
+ export self
191
+
192
+ # site/index.rb
193
+ Utils = import '_lib/utils'
194
+
195
+ export template {
196
+ h1 "foo = #{Utils.foo}"
197
+ }
198
+ ```
199
+
200
+ ## Hooks (a.k.a. Middleware)
201
+
202
+ A hook is a piece of code that can intercept HTTP requests before they are
203
+ passed off to the correspending route. Hooks are applied to the subtree of the
204
+ directory in which they reside.
205
+
206
+ Hooks can be used for a variety of purposes:
207
+
208
+ - Parameter validation
209
+ - Authentication, authorization & session management
210
+ - Logging
211
+ - Request rewriting / redirecting
212
+
213
+ When multiple hooks are defined up the tree for a particular route, they are
214
+ chained together such that each hook is invoked starting from the file tree root
215
+ and down to the route path.
216
+
217
+ Hooks are implemented as modules named `_hook.rb`, that export procs (or
218
+ callables) with the following signature:
219
+
220
+ ```ruby
221
+ # **/_hook.rb
222
+ export ->(req, app) { ... }
223
+ ```
224
+
225
+ ... where req is the request object, and app is the callable that code. Here's
226
+ an example of an authorization hook:
227
+
228
+ ```ruby
229
+ export ->(req, app) {
230
+ if (!req.cookies[:session_id])
231
+ req.redirect('/signin')
232
+ else
233
+ app.(req)
234
+ end
235
+ }
236
+ ```
237
+
238
+ ## Error handlers
239
+
240
+ An error handler can be defined separately for each subtree. When an exception
241
+ is raised that is not rescued by the application code, Syntropy will look for an
242
+ error handler up the file tree, and will invoke the first error handler found.
243
+
244
+ Error handlers are implemented as modules named `_error.rb`, that export procs (or
245
+ callables) with the following signature:
246
+
247
+ ```ruby
248
+ # **/_error.rb
249
+ ->(req, err) { ... }
250
+ ```
251
+
252
+ Using different error handlers for parts of the route tree allows different
253
+ error responses for each route. For example, the error response for an API route
254
+ can be a JSON object, while the error response for a browser page route can be a
255
+ custom HTML page.
data/lib/syntropy/app.rb CHANGED
@@ -32,20 +32,20 @@ module Syntropy
32
32
  end
33
33
  end
34
34
 
35
- def initialize(machine, src_path, mount_path, opts = {})
35
+ def initialize(machine, location, mount_path, opts = {})
36
36
  @machine = machine
37
- @src_path = File.expand_path(src_path)
37
+ @location = File.expand_path(location)
38
38
  @mount_path = mount_path
39
39
  @opts = opts
40
40
 
41
- @module_loader = Syntropy::ModuleLoader.new(@src_path, @opts)
41
+ @module_loader = Syntropy::ModuleLoader.new(@location, @opts)
42
42
  @router = Syntropy::Router.new(@opts, @module_loader)
43
43
 
44
44
  @machine.spin do
45
45
  # we do startup stuff asynchronously, in order to first let TP2 do its
46
46
  # setup tasks
47
47
  @machine.sleep 0.15
48
- @opts[:logger]&.call("Serving from #{File.expand_path(@src_path)}")
48
+ @opts[:logger]&.call("Serving from #{File.expand_path(@location)}")
49
49
  @router.start_file_watcher if opts[:watch_files]
50
50
  end
51
51
  end
@@ -135,7 +135,7 @@ module Syntropy
135
135
  end
136
136
 
137
137
  def load_module(entry)
138
- ref = entry[:fn].gsub(%r{^#{@src_path}/}, '').gsub(/\.rb$/, '')
138
+ ref = entry[:fn].gsub(%r{^#{@location}/}, '').gsub(/\.rb$/, '')
139
139
  o = @module_loader.load(ref)
140
140
  o.is_a?(Papercraft::Template) ? wrap_template(o) : o
141
141
  rescue Exception => e
@@ -34,9 +34,7 @@ module Syntropy
34
34
  private
35
35
 
36
36
  def checkout
37
- if @queue.count == 0 && @count < @max_conn
38
- return create_db
39
- end
37
+ return make_db_instance if @queue.count == 0 && @count < @max_conn
40
38
 
41
39
  @machine.shift(@queue)
42
40
  end
@@ -45,16 +43,11 @@ module Syntropy
45
43
  @machine.push(@queue, db)
46
44
  end
47
45
 
48
- def create_db
49
- db = Extralite::Database.new(@fn, wal: true)
50
- setup_db(db)
51
- @count += 1
52
- db
53
- end
54
-
55
- def setup_db(db)
56
- # setup WAL, sync
57
- # setup concurrency stuff
46
+ def make_db_instance
47
+ Extralite::Database.new(@fn, wal: true).tap do
48
+ @count += 1
49
+ it.on_progress(mode: :at_least_once, period: 320, tick: 10) { @machine.snooze }
50
+ end
58
51
  end
59
52
  end
60
53
  end
@@ -33,6 +33,7 @@ module Syntropy
33
33
  mod_body = IO.read(fn)
34
34
  mod_ctx = Class.new(Syntropy::Module)
35
35
  mod_ctx.loader = self
36
+ mod_ctx.env = @env
36
37
  mod_ctx.module_eval(mod_body, fn, 1)
37
38
 
38
39
  export_value = mod_ctx.__export_value__
@@ -66,6 +67,10 @@ module Syntropy
66
67
  @loader = loader
67
68
  end
68
69
 
70
+ def self.env=(env)
71
+ @env = env
72
+ end
73
+
69
74
  def self.import(ref)
70
75
  @loader.load(ref)
71
76
  end
@@ -74,12 +79,29 @@ module Syntropy
74
79
  @__export_value__ = ref
75
80
  end
76
81
 
77
- def self.templ(&block)
82
+ def self.template(&block)
78
83
  Papercraft.html(&block)
79
84
  end
80
85
 
86
+ def self.route_by_host
87
+ root = @env[:location]
88
+ sites = Dir[File.join(root, '*')]
89
+ .select { File.directory?(it) }
90
+ .inject({}) { |h, fn|
91
+ name = File.basename(fn)
92
+ opts = @env.merge(location: fn)
93
+ h[name] = Syntropy::App.new(opts[:machine], opts[:location], opts[:mount_path], opts)
94
+ h
95
+ }
96
+ ->(req) {
97
+ site = sites[req.host]
98
+ site ? site.call(req) : req.respond(nil, ':status' => Status::BAD_REQUEST)
99
+ }
100
+ end
101
+
81
102
  def self.__export_value__
82
103
  @__export_value__
83
104
  end
105
+
84
106
  end
85
107
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syntropy
4
- VERSION = '0.6'
4
+ VERSION = '0.7'
5
5
  end
data/test/app/_hook.rb CHANGED
@@ -1,5 +1,4 @@
1
1
  export ->(req, proc) {
2
2
  req.ctx[:foo] = req.query[:foo]
3
- # p proc: proc
4
3
  proc.(req)
5
4
  }
@@ -1,4 +1,4 @@
1
- ->(**props) {
1
+ export template { |**props|
2
2
  header {
3
3
  h1 'Foo'
4
4
  }
@@ -0,0 +1,5 @@
1
+ def foo
2
+ :bar
3
+ end
4
+
5
+ export self
data/test/app/api+.rb CHANGED
@@ -15,6 +15,10 @@ class API < Syntropy::RPCAPI
15
15
 
16
16
  @count += 1
17
17
  end
18
+
19
+ def req(req)
20
+ { query: req.query, headers: req.headers }
21
+ end
18
22
  end
19
23
 
20
24
  export API
@@ -0,0 +1 @@
1
+ export route_by_host
@@ -0,0 +1 @@
1
+ <h1>bar.baz</h1>
@@ -0,0 +1 @@
1
+ <h1>foo.bar</h1>
data/test/test_app.rb CHANGED
@@ -180,3 +180,36 @@ class CustomAppTest < Minitest::Test
180
180
  assert_equal Status::TEAPOT, req.response_status
181
181
  end
182
182
  end
183
+
184
+ class MultiSiteAppTest < Minitest::Test
185
+ Status = Qeweney::Status
186
+
187
+ APP_ROOT = File.join(__dir__, 'app_multi_site')
188
+
189
+ def setup
190
+ @machine = UM.new
191
+ @app = Syntropy::App.load(
192
+ machine: @machine,
193
+ location: APP_ROOT,
194
+ mount_path: '/'
195
+ )
196
+ end
197
+
198
+ def make_request(*, **)
199
+ req = mock_req(*, **)
200
+ @app.call(req)
201
+ req
202
+ end
203
+
204
+ def test_route_by_host
205
+ req = make_request(':method' => 'GET', ':path' => '/', 'host' => 'blah')
206
+ assert_nil req.response_body
207
+ assert_equal Status::BAD_REQUEST, req.response_status
208
+
209
+ req = make_request(':method' => 'GET', ':path' => '/', 'host' => 'foo.bar')
210
+ assert_equal '<h1>foo.bar</h1>', req.response_body.chomp
211
+
212
+ req = make_request(':method' => 'GET', ':path' => '/', 'host' => 'bar.baz')
213
+ assert_equal '<h1>bar.baz</h1>', req.response_body.chomp
214
+ end
215
+ end
data/test/test_module.rb CHANGED
@@ -29,4 +29,10 @@ class ModuleTest < Minitest::Test
29
29
  @env[:baz] += 1
30
30
  assert_equal 43, mod.bar
31
31
  end
32
+
33
+ def test_export_self
34
+ mod = @loader.load('_lib/self')
35
+ assert_kind_of Syntropy::Module, mod
36
+ assert_equal :bar, mod.foo
37
+ end
32
38
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: syntropy
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.6'
4
+ version: '0.7'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sharon Rosner
@@ -207,6 +207,7 @@ files:
207
207
  - test/app/_lib/callable.rb
208
208
  - test/app/_lib/klass.rb
209
209
  - test/app/_lib/missing-export.rb
210
+ - test/app/_lib/self.rb
210
211
  - test/app/about/_error.rb
211
212
  - test/app/about/foo.md
212
213
  - test/app/about/index.rb
@@ -218,6 +219,9 @@ files:
218
219
  - test/app/index.html
219
220
  - test/app/tmp.rb
220
221
  - test/app_custom/_site.rb
222
+ - test/app_multi_site/_site.rb
223
+ - test/app_multi_site/bar.baz/index.html
224
+ - test/app_multi_site/foo.bar/index.html
221
225
  - test/helper.rb
222
226
  - test/run.rb
223
227
  - test/test_app.rb