syntropy 0.5 → 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: 7f125135bc3ed83c1050467241f48caf366600fead1d75db79455038e42e311a
4
- data.tar.gz: ee9bd2bbf906eea9c90481ecdf643126285f65f63fe66a3f2b4f3050893e7c28
3
+ metadata.gz: b86a65bfeb990aa38b76b5bb6487210887e7103bab652e40c3790d68c1ab48df
4
+ data.tar.gz: 89397a9faf63d2a07c69ab69f93048cb92c82014e30d7ca6ed9399671c17bb48
5
5
  SHA512:
6
- metadata.gz: 0bdce31c490701521d73829c02e1193fcd0da399b1e6186066ad5d6ecfa067dc143941300bf670fa4b447d5d5c177e903fa273d9d6d5bbfa2319ec0c74fd2678
7
- data.tar.gz: 7b5eddf105f8654f379bf1d7f52948b713ee342dbd97eb1896e28b618b442ccf33c716d27a7f67c21cfd2c65f610b5c9677cd5e4faa0e777b8f727552628865c
6
+ metadata.gz: 0f536c81be4baa5c8e8733705a9a41042b322eb3960ee8a092523bfdfff886893a4183a6b00c69c2960daefe6443a6f06ee0e07596eeb5ffc3b45ceceb86c342
7
+ data.tar.gz: 05c3e6c5d3641847a0c962811e290313f7d08a4233504466035e94cfc03284cf652f990f5fd4681efd059673050fda5d021bba5a982c0e539b8c3351bd316913
data/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 0.7 2025-07-05
2
+
3
+ - Implement `Module.route_by_host`
4
+ - Add snoozing on DB progress
5
+
6
+ ## 0.6 2025-07-05
7
+
8
+ - Add support for middleware and error handlers
9
+
1
10
  ## 0.5 2025-07-05
2
11
 
3
12
  - Refactor App class to use Router
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/TODO.md CHANGED
@@ -1,44 +1,33 @@
1
- - Refactor routing code into a separate Router class.
2
- - The Router class is in charge of:
3
- - caching routes
4
- - loading modules
5
- - unloading modules on file change
6
- - calculating middleware for routes
7
- - middleware is defined in `_hook.rb` modules
8
- - interface: ->(req, next)
9
- - a special case for handling errors is `_error.rb`
10
- - interface: ->(req, err)
11
- - dispatching routes
12
- - error handling:
13
- - on uncaught error, if an `_error.rb` file exists in the same directory
14
- or up the file tree
15
- - middleware:
16
- - a closure is created from the composition of the different hooks
17
- defined, from the route's directory and up the file
18
- - error handlers and middleware closures are cached as part of the route's
19
- entry
20
- - on file change for any _hook.rb or _error.rb files, all route entries in
21
- the corresponding subtree are invalidated
22
-
23
-
24
- - Middleware
1
+ - Some standard middleware:
2
+
3
+ - request rewriter
4
+ - logger
5
+ - auth
6
+ - selector + terminator
25
7
 
26
8
  ```Ruby
27
- # site/_hook.rb
28
- export ->(req, &app) do
29
- app.call(req)
30
- rescue Syntropy::Error => e
31
- render_error_page(req, e.http_status)
9
+ # For the chainable DSL shown below, we need to create a custom class:
10
+ class Syntropy::Middleware::Selector
11
+ def initialize(select_proc, terminator_proc = nil)
12
+ @select_proc = select_proc
13
+ @terminator_proc = terminator_proc
14
+ end
15
+
16
+ def to_proc
17
+ ->(req, proc) {
18
+ @select_proc.(req) ? @terminator_proc.(req) : proc(req)
19
+ }
20
+ end
21
+
22
+ def terminate(&proc)
23
+ @terminator_proc = proc
24
+ end
32
25
  end
33
26
 
34
- # an alternative, at least for errors is a _error.rb file:
35
- # site/_error.rb
36
- # Just a normal callable:
37
- #
38
- export ->(req, err) do
39
- render_error_page(req, err.http_status)
40
- end
27
+ def Syntropy
28
+ ```
41
29
 
30
+ ```Ruby
42
31
  # a _site.rb file can be used to wrap a whole app
43
32
  # site/_site.rb
44
33
 
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
@@ -65,21 +65,32 @@ module Syntropy
65
65
  private
66
66
 
67
67
  def render_entry(req, entry)
68
+ kind = entry[:kind]
69
+ return respond_not_found(req) if kind == :not_found
70
+
71
+ entry[:proc] ||= calculate_route_proc(entry)
72
+ entry[:proc].(req)
73
+ end
74
+
75
+ def calculate_route_proc(entry)
76
+ render_proc = route_render_proc(entry)
77
+ @router.calc_route_proc_with_hooks(entry, render_proc)
78
+ end
79
+
80
+ def route_render_proc(entry)
68
81
  case entry[:kind]
69
- when :not_found
70
- respond_not_found(req, entry)
71
82
  when :static
72
- respond_static(req, entry)
83
+ ->(req) { respond_static(req, entry) }
73
84
  when :markdown
74
- respond_markdown(req, entry)
85
+ ->(req) { respond_markdown(req, entry) }
75
86
  when :module
76
- respond_module(req, entry)
87
+ load_module(entry)
77
88
  else
78
89
  raise 'Invalid entry kind'
79
90
  end
80
91
  end
81
92
 
82
- def respond_not_found(req, _entry)
93
+ def respond_not_found(req)
83
94
  headers = { ':status' => Qeweney::Status::NOT_FOUND }
84
95
  case req.method
85
96
  when 'head'
@@ -124,7 +135,7 @@ module Syntropy
124
135
  end
125
136
 
126
137
  def load_module(entry)
127
- ref = entry[:fn].gsub(%r{^#{@src_path}/}, '').gsub(/\.rb$/, '')
138
+ ref = entry[:fn].gsub(%r{^#{@location}/}, '').gsub(/\.rb$/, '')
128
139
  o = @module_loader.load(ref)
129
140
  o.is_a?(Papercraft::Template) ? wrap_template(o) : o
130
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
@@ -2,8 +2,6 @@
2
2
 
3
3
  module Syntropy
4
4
  class Router
5
- attr_reader :cache
6
-
7
5
  def initialize(opts, module_loader = nil)
8
6
  raise 'Invalid location given' if !File.directory?(opts[:location])
9
7
 
@@ -32,6 +30,10 @@ module Syntropy
32
30
  @machine.spin { file_watcher_loop }
33
31
  end
34
32
 
33
+ def calc_route_proc_with_hooks(entry, proc)
34
+ compose_up_tree_hooks(entry[:fn], proc)
35
+ end
36
+
35
37
  private
36
38
 
37
39
  HIDDEN_RE = /^_/
@@ -204,5 +206,35 @@ module Syntropy
204
206
  def remove_entry_cache_keys(entry)
205
207
  entry[:cache_keys]&.each_key { @cache.delete(it) }.clear
206
208
  end
209
+
210
+ def compose_up_tree_hooks(path, proc)
211
+ parent = File.dirname(path)
212
+ proc = hook_wrap_if_exists(File.join(parent, '_hook.rb'), proc)
213
+ proc = error_handler_wrap_if_exists(File.join(parent, '_error.rb'), proc)
214
+ return proc if parent == @root
215
+
216
+ compose_up_tree_hooks(parent, proc)
217
+ end
218
+
219
+ def hook_wrap_if_exists(hook_fn, proc)
220
+ return proc if !File.file?(hook_fn)
221
+
222
+ ref = path_rel(hook_fn).gsub(/\.rb$/, '')
223
+ hook_proc = @module_loader.load(ref)
224
+ ->(req) { hook_proc.(req, proc) }
225
+ end
226
+
227
+ def error_handler_wrap_if_exists(error_handler_fn, proc)
228
+ return proc if !File.file?(error_handler_fn)
229
+
230
+ ref = path_rel(error_handler_fn).gsub(/\.rb$/, '')
231
+ error_proc = @module_loader.load(ref)
232
+
233
+ proc do |req|
234
+ proc.(req)
235
+ rescue StandardError => e
236
+ error_proc.(req, e)
237
+ end
238
+ end
207
239
  end
208
240
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syntropy
4
- VERSION = '0.5'
4
+ VERSION = '0.7'
5
5
  end
data/test/app/_hook.rb ADDED
@@ -0,0 +1,4 @@
1
+ export ->(req, proc) {
2
+ req.ctx[:foo] = req.query[:foo]
3
+ proc.(req)
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
@@ -0,0 +1,6 @@
1
+ DEFAULT_STATUS = Qeweney::Status::INTERNAL_SERVER_ERROR
2
+
3
+ export ->(req, err) {
4
+ status = err.respond_to?(:http_status) ? err.http_status : DEFAULT_STATUS
5
+ req.respond("<h1>#{err.message}</h1>", ':status' => status, 'Content-Type' => 'text/html')
6
+ }
@@ -0,0 +1,3 @@
1
+ export ->(req) {
2
+ raise 'Raised error'
3
+ }
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
@@ -140,6 +140,18 @@ class AppTest < Minitest::Test
140
140
  ensure
141
141
  IO.write(@tmp_fn, orig_body) if orig_body
142
142
  end
143
+
144
+ def test_middleware
145
+ req = make_request(':method' => 'HEAD', ':path' => '/test?foo=42')
146
+ assert_equal Status::OK, req.response_status
147
+ assert_nil req.response_body
148
+ assert_equal '42', req.ctx[:foo]
149
+
150
+ req = make_request(':method' => 'HEAD', ':path' => '/test/about/raise?foo=43')
151
+ assert_equal Status::INTERNAL_SERVER_ERROR, req.response_status
152
+ assert_equal '<h1>Raised error</h1>', req.response_body
153
+ assert_equal '43', req.ctx[:foo]
154
+ end
143
155
  end
144
156
 
145
157
  class CustomAppTest < Minitest::Test
@@ -168,3 +180,36 @@ class CustomAppTest < Minitest::Test
168
180
  assert_equal Status::TEAPOT, req.response_status
169
181
  end
170
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.5'
4
+ version: '0.7'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sharon Rosner
@@ -202,12 +202,16 @@ files:
202
202
  - lib/syntropy/side_run.rb
203
203
  - lib/syntropy/version.rb
204
204
  - syntropy.gemspec
205
+ - test/app/_hook.rb
205
206
  - test/app/_layout/default.rb
206
207
  - test/app/_lib/callable.rb
207
208
  - test/app/_lib/klass.rb
208
209
  - test/app/_lib/missing-export.rb
210
+ - test/app/_lib/self.rb
211
+ - test/app/about/_error.rb
209
212
  - test/app/about/foo.md
210
213
  - test/app/about/index.rb
214
+ - test/app/about/raise.rb
211
215
  - test/app/api+.rb
212
216
  - test/app/assets/style.css
213
217
  - test/app/bar.rb
@@ -215,6 +219,9 @@ files:
215
219
  - test/app/index.html
216
220
  - test/app/tmp.rb
217
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
218
225
  - test/helper.rb
219
226
  - test/run.rb
220
227
  - test/test_app.rb