syntropy 0.36.0 → 0.38.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/Gemfile +4 -0
  4. data/TODO.md +4 -0
  5. data/bin/syntropy +12 -2
  6. data/cmd/help.rb +4 -0
  7. data/cmd/new/template/.gitignore +2 -1
  8. data/cmd/new/template/config/Caddyfile +5 -0
  9. data/cmd/new/template/docker-compose.yml +28 -3
  10. data/cmd/new.rb +7 -1
  11. data/cmd/serve.rb +3 -1
  12. data/cmd/version.rb +14 -0
  13. data/examples/basic/counter_api.rb +1 -1
  14. data/examples/blog/app/posts/[id]/edit.rb +1 -1
  15. data/examples/blog/app/posts/[id]/index.rb +1 -1
  16. data/examples/blog/app/posts/index.rb +1 -1
  17. data/examples/blog/app/posts/new.rb +1 -1
  18. data/examples/github/app/[org]/[repo]/index.rb +0 -0
  19. data/examples/github/app/[org]/[repo]/issues/[id].rb +0 -0
  20. data/examples/github/app/[org]/index.rb +0 -0
  21. data/examples/github/app/collections.rb +0 -0
  22. data/examples/github/app/explore.rb +0 -0
  23. data/examples/github/app/index.rb +0 -0
  24. data/lib/syntropy/app.rb +6 -2
  25. data/lib/syntropy/controller_extensions.rb +136 -0
  26. data/lib/syntropy/http/io_extensions.rb +9 -0
  27. data/lib/syntropy/http/server_connection.rb +1 -0
  28. data/lib/syntropy/json_api.rb +5 -0
  29. data/lib/syntropy/module_loader.rb +46 -42
  30. data/lib/syntropy/routing_tree.rb +14 -14
  31. data/lib/syntropy/storage/schema.rb +3 -3
  32. data/lib/syntropy/test.rb +29 -11
  33. data/lib/syntropy/version.rb +1 -1
  34. data/lib/syntropy.rb +3 -6
  35. data/test/bm_router_proc.rb +14 -15
  36. data/test/fixtures/app/_lib/klass.rb +1 -1
  37. data/test/fixtures/app/api+.rb +1 -1
  38. data/test/fixtures/app/bad_mod_arity.rb +3 -0
  39. data/test/fixtures/app/by_method.rb +1 -1
  40. data/test/fixtures/app/post_ct.rb +1 -1
  41. data/test/fixtures/app_errors/_error.rb +3 -0
  42. data/test/fixtures/app_errors/foo/_error.rb +3 -0
  43. data/test/fixtures/app_errors/foo/bar/_error.rb +3 -0
  44. data/test/fixtures/app_errors/foo/bar/baz/index.rb +3 -0
  45. data/test/fixtures/app_errors/foo/bar/index.rb +3 -0
  46. data/test/fixtures/app_errors/foo/index.rb +3 -0
  47. data/test/fixtures/app_errors/index.rb +3 -0
  48. data/test/fixtures/app_hooks/_hook.rb +4 -0
  49. data/test/fixtures/app_hooks/foo/_hook.rb +4 -0
  50. data/test/fixtures/app_hooks/foo/bar/_hook.rb +4 -0
  51. data/test/fixtures/app_hooks/foo/bar/baz/_hook.rb +4 -0
  52. data/test/fixtures/app_hooks/foo/bar/baz/index.rb +3 -0
  53. data/test/fixtures/app_hooks/foo/bar/index.rb +3 -0
  54. data/test/fixtures/app_hooks/foo/index.rb +3 -0
  55. data/test/fixtures/app_hooks/index.rb +3 -0
  56. data/test/fixtures/app_multi_site/_site.rb +1 -1
  57. data/test/fixtures/controllers/by_host/bar.com/index.rb +3 -0
  58. data/test/fixtures/controllers/by_host/foo.com/index.rb +3 -0
  59. data/test/fixtures/controllers/by_host_dir.rb +1 -0
  60. data/test/fixtures/controllers/by_host_dir_map.rb +4 -0
  61. data/test/fixtures/controllers/by_host_map.rb +4 -0
  62. data/test/fixtures/controllers/by_http_method.rb +9 -0
  63. data/test/fixtures/controllers/jsonrpc_endpoint.rb +0 -0
  64. data/test/test_app.rb +86 -1
  65. data/test/test_controller.rb +71 -0
  66. data/test/test_http_protocol.rb +54 -0
  67. data/test/test_module_loader.rb +43 -5
  68. data/test/test_routing_tree.rb +1 -0
  69. data/test/test_test.rb +1 -1
  70. metadata +34 -2
  71. data/lib/syntropy/utils.rb +0 -87
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f92ed1375642f12fb92654d872bfde1d07c7ad74be97507af3c1616390b6ce14
4
- data.tar.gz: 5072d514bc07149f1d26bc900bcc9379970ebbbaf1b88eaa23933189fb684b37
3
+ metadata.gz: 85989e43beae58a319d108e1c8cf42f8265fdfaa0675c0fa2b0b8c8df5dbfbd1
4
+ data.tar.gz: cd2dd5db7add2b3e3aa74f2a3fa02da3c1896b808a46be16b088e264875aec10
5
5
  SHA512:
6
- metadata.gz: f77aed02801a7b1c10f67a5ed7fbac9196580bd482c19ca660c81beaf5f2adcba86047635e35f9750d003538b12401962888193c058fbc3023ae88eae9038506
7
- data.tar.gz: f923ab3aa4e535d5a85a563acf7f97534639ca1ed3d02e2c2b94d9cecef57e06cefc3a72a1ca782d0118b012189298bc3c743b017c0ee2d06be8bbf55d03dc89
6
+ metadata.gz: 3cd0fb5d085456bc8d51db049fecaf11f000071cde6b19dd224c7ec422e1fb749be3ba92dfd5b8a43e3e95313f99b1ffed5f926ca37abd5377e2badd249576f3
7
+ data.tar.gz: c3a2b789fe2c9cfbff79b2245381f3ee76bad2e905e3f8408df2666af1495b51f93988d7085c1957330b194237f366e0999b43eb25b8e0e42954f07146aaa5cb
data/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ # 0.38.0 2026-06-13
2
+
3
+ - Reimplement controller extensions: `dispatch_by_host`,
4
+ `dispatch_by_http_method`
5
+ - Fix middleware composition
6
+ - Add CLI command shortcuts
7
+ - Rename `Syntropy::Module` to `Syntropy::ModuleContext`
8
+
9
+ # 0.37.0 2026-06-07
10
+
11
+ - Call `IO#clear` before closing server connection
12
+ - syntropy new:
13
+ - Remove socket mapping for backend in docker-compose.yml
14
+ - Add caddy reverse proxy to template
15
+ - Add overwrite confirmation, better file copying
16
+ - Verify storage module `migrate!` method exists
17
+ - Fix `set_schema_version` for usage in PG DB
18
+ - Do not raise exception on missing config module
19
+ - Do not convert class export value to class instance, allow exporting a
20
+ class
21
+ - Fix HTTP protocol error when pipelining post requests with empty body
22
+ - Setup fiber scheduler when running server
23
+
1
24
  # 0.36.0 2026-06-04
2
25
 
3
26
  - Rename `DB` to `Storage`
data/Gemfile CHANGED
@@ -1,3 +1,7 @@
1
1
  source 'https://gem.coop'
2
2
 
3
3
  gemspec
4
+
5
+ group :development do
6
+ gem 'roda'
7
+ end
data/TODO.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## Immediate
2
2
 
3
+ - [ ] Controllers
4
+ - [ ] Streamline names of ready-made control methods:
5
+ - [ ] dispatch_json_rpc
6
+
3
7
  - [ ] Collection - treat directories and files as collections of data.
4
8
 
5
9
  Kind of similar to the routing tree, but instead of routes it just takes a
data/bin/syntropy CHANGED
@@ -1,13 +1,23 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative '../cmd/_banner'
5
-
6
4
  def cmd_fn(cmd)= File.join(__dir__, "../cmd/#{cmd}.rb")
7
5
 
8
6
  cmd = ARGV.shift || 'help'
9
7
  cmd = 'help' if cmd !~ /^[a-z]+$/
10
8
 
9
+ SHORTCUTS = {
10
+ 'c' => 'console',
11
+ 'n' => 'new',
12
+ 's' => 'serve',
13
+ 't' => 'test',
14
+ 'v' => 'version'
15
+ }
16
+
17
+ if (target_cmd = SHORTCUTS[cmd])
18
+ cmd = target_cmd
19
+ end
20
+
11
21
  fn = cmd_fn(cmd)
12
22
  fn = cmd_fn('help') if !File.file?(fn)
13
23
 
data/cmd/help.rb CHANGED
@@ -5,8 +5,12 @@ HELP = <<~MSG
5
5
 
6
6
  Available commands:
7
7
 
8
+ console Start an IRB session
9
+ help Show this message
10
+ new Create a new Syntropy app
8
11
  serve Start a Syntropy server
9
12
  test Run tests
13
+ version Show version information
10
14
  MSG
11
15
 
12
16
  $stdout << HELP
@@ -1,3 +1,4 @@
1
1
  Gemfile.lock
2
- storage/*
2
+ storage/*.db*
3
+ storage/caddy/*
3
4
  vendor/bundle
@@ -0,0 +1,5 @@
1
+ localhost {
2
+ reverse_proxy app_server:1234
3
+ tls internal
4
+ encode
5
+ }
@@ -7,17 +7,18 @@ services:
7
7
  - seccomp:unconfined
8
8
  volumes:
9
9
  - .:/syntropy
10
- ports:
11
- - "1234:1234"
12
10
  stop_signal: SIGINT
13
11
  stop_grace_period: 10s
14
- restart: always
12
+ restart: unless-stopped
15
13
  healthcheck:
16
14
  test: "curl 'http://localhost:1234/'"
17
15
  interval: "30s"
18
16
  timeout: "3s"
19
17
  start_period: "5s"
20
18
  retries: 3
19
+ networks:
20
+ - proxy_network
21
+
21
22
  console:
22
23
  build: .
23
24
  command: bundle exec syntropy console
@@ -31,6 +32,7 @@ services:
31
32
  - .:/syntropy
32
33
  stop_signal: SIGINT
33
34
  restart: never
35
+
34
36
  test:
35
37
  build: .
36
38
  command: bundle exec syntropy test -w
@@ -44,3 +46,26 @@ services:
44
46
  - .:/syntropy
45
47
  stop_signal: SIGINT
46
48
  restart: never
49
+
50
+ proxy:
51
+ depends_on:
52
+ - app_server
53
+ image: caddy:2-alpine
54
+ build:
55
+ context: ./proxy
56
+ dockerfile: Dockerfile
57
+ restart: unless-stopped
58
+ ports:
59
+ - "80:80"
60
+ - "443:443"
61
+ - "443:443/udp"
62
+ volumes:
63
+ - ./config/Caddyfile:/etc/caddy/Caddyfile
64
+ - ./storage/caddy/data:/data
65
+ - ./storage/caddy/config:/config
66
+ networks:
67
+ - proxy_network
68
+
69
+ networks:
70
+ proxy_network:
71
+ name: proxy_network
data/cmd/new.rb CHANGED
@@ -3,9 +3,15 @@
3
3
  require 'optparse'
4
4
  require 'fileutils'
5
5
 
6
+ opts = {}
7
+
6
8
  parser = OptionParser.new do |o|
7
9
  o.banner = 'Usage: syntropy new NAME [options]'
8
10
 
11
+ o.on('-y', '--yes', 'Confirm all overwrites') do
12
+ opts[:yes] = true
13
+ end
14
+
9
15
  o.on('-h', '--help', 'Show this help message') do
10
16
  puts o
11
17
  exit
@@ -37,7 +43,7 @@ template_path = File.join(__dir__, 'new/template')
37
43
 
38
44
  begin
39
45
  `mkdir -p "#{path}"`
40
- `cp -r #{template_path}/* "#{path}/"`
46
+ system("cp -rv#{opts[:yes] ? '' : 'i'} #{template_path}/* \"#{path}/\"")
41
47
  puts "Your app is ready in #{path}"
42
48
  rescue => e
43
49
  p e
data/cmd/serve.rb CHANGED
@@ -94,7 +94,9 @@ env[:banner] = false
94
94
  env[:machine] = Syntropy.machine = UM.new
95
95
  env[:logger] = env[:logger] && Syntropy::Logger.new(env[:machine], **env)
96
96
 
97
- require 'syntropy/version'
97
+ require 'uringmachine/fiber_scheduler'
98
+ Fiber.set_scheduler(UM::FiberScheduler.new(env[:machine]))
99
+
98
100
  require 'syntropy/dev_mode' if Syntropy.dev_mode
99
101
 
100
102
  app = Syntropy::App.load(env)
data/cmd/version.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'syntropy/version'
4
+ require 'uringmachine/version'
5
+ require_relative './_banner'
6
+
7
+ VERSION = <<~MSG
8
+ Syntropy version #{Syntropy::VERSION}
9
+ UringMachine version #{UringMachine::VERSION}
10
+ Ruby version #{RUBY_VERSION}
11
+ MSG
12
+
13
+ $stdout << SYNTROPY_BANNER
14
+ $stdout << VERSION
@@ -19,4 +19,4 @@ class CounterAPI < Syntropy::JSONAPI
19
19
  end
20
20
  end
21
21
 
22
- export CounterAPI
22
+ export CounterAPI.new(@env)
@@ -1,7 +1,7 @@
1
1
  @posts = import '/_lib/posts'
2
2
  @layout = import '/_layout/default'
3
3
 
4
- export http_methods
4
+ export dispatch_by_http_method
5
5
 
6
6
  def get(req)
7
7
  id = req.route_params['id'].to_i
@@ -1,7 +1,7 @@
1
1
  @posts = import '/_lib/posts'
2
2
  @layout = import '/_layout/default'
3
3
 
4
- export http_methods
4
+ export dispatch_by_http_method
5
5
 
6
6
  def get(req)
7
7
  id = req.route_params['id'].to_i
@@ -1,7 +1,7 @@
1
1
  @posts = import '_lib/posts'
2
2
  @layout = import '_layout/default'
3
3
 
4
- export http_methods
4
+ export dispatch_by_http_method
5
5
 
6
6
  def get(req)
7
7
  posts = @posts.get_all
@@ -1,7 +1,7 @@
1
1
  @posts = import '/_lib/posts'
2
2
  @layout = import '/_layout/default'
3
3
 
4
- export http_methods
4
+ export dispatch_by_http_method
5
5
 
6
6
  def get(req)
7
7
  req.respond_html(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
data/lib/syntropy/app.rb CHANGED
@@ -9,6 +9,7 @@ require 'syntropy/errors'
9
9
  require 'syntropy/module_loader'
10
10
  require 'syntropy/routing_tree'
11
11
  require 'syntropy/mime_types'
12
+ require 'syntropy/controller_extensions'
12
13
 
13
14
  module Syntropy
14
15
  # The App implements a Syntropy application. It is responsible for handling
@@ -34,7 +35,7 @@ module Syntropy
34
35
  fn = File.join(env[:app_root], '_site.rb')
35
36
  return nil if !File.file?(fn)
36
37
 
37
- loader = Syntropy::ModuleLoader.new(env)
38
+ loader = Syntropy::ModuleLoader.new(env, extensions: ControllerExtensions)
38
39
  loader.load('_site')
39
40
  end
40
41
 
@@ -61,7 +62,10 @@ module Syntropy
61
62
  @env = env
62
63
  @logger = env[:logger]
63
64
 
64
- @module_loader = Syntropy::ModuleLoader.new(app: self, **env)
65
+ @module_loader = Syntropy::ModuleLoader.new(
66
+ env.merge(app: self),
67
+ extensions: ControllerExtensions
68
+ )
65
69
  setup_routing_tree
66
70
  start
67
71
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Syntropy
6
+ # Utilities for use in modules
7
+ module ControllerExtensions
8
+ # Returns a unique temporary path
9
+ #
10
+ # @param prefix [String] temp file prefix
11
+ # @return [String]
12
+ def tmp_path(prefix = 'syntropy')
13
+ "/tmp/#{prefix}-#{SecureRandom.hex(16)}"
14
+ end
15
+
16
+ # Returns a request handler that routes request according to the host
17
+ # header. Looks for site directories (named by host name) in the app's root
18
+ # directory. A map may be given in order to provide additional hostnames to
19
+ # site directories.
20
+ #
21
+ # @param dir [String, nil] relative directory path for host sites
22
+ # @param map [Hash, nil] hash mapping host names to relative site directory
23
+ # @return [Proc] router proc
24
+ def dispatch_by_host(dir = nil, map = nil)
25
+ raise Syntropy::Error, 'Must provide dir and/or map' if !dir && !map
26
+
27
+ site_map = {}
28
+ setup_directory_sites(dir, site_map) if dir
29
+ setup_mapped_sites(map, site_map) if map
30
+
31
+ ->(req) do
32
+ site = site_map[req.host]
33
+ site ? site.call(req) : req.respond(nil, ':status' => HTTP::BAD_REQUEST)
34
+ end
35
+ end
36
+
37
+ # Returns a request handler that handles requests by calling the appropriate
38
+ # module method (e.g. get, post, etc.)
39
+ #
40
+ # @return [Proc]
41
+ def dispatch_by_http_method
42
+ ->(req) do
43
+ route_by_http_method(req)
44
+ end
45
+ end
46
+
47
+ # Returns a list of parsed markdown pages at the given path.
48
+ #
49
+ # @param env [Hash] app environment hash
50
+ # @param ref [String] directory path
51
+ # @return [Array<Hash>] array of page entries
52
+ def page_list(env, ref)
53
+ full_path = File.join(env[:app_root], ref)
54
+ raise 'Not a directory' if !File.directory?(full_path)
55
+
56
+ Dir[File.join(full_path, '*.md')].sort.map {
57
+ atts, markdown = Syntropy::Markdown.parse(it, env)
58
+ { atts:, markdown: }
59
+ }
60
+ end
61
+
62
+ # Instantiates a Syntropy app for the given environment hash.
63
+ #
64
+ # @return [Syntropy::App]
65
+ def app(**)
66
+ Syntropy::App.new(**)
67
+ end
68
+
69
+ BUILTIN_APPLET_app_root = File.expand_path(File.join(__dir__, 'applets/builtin'))
70
+
71
+ # Creates a builtin applet with the given environment hash. By default the
72
+ # builtin applet is mounted at /.syntropy.
73
+ #
74
+ # @param env [Hash] app environment
75
+ # @param mount_path [String] mount path for the builtin applet
76
+ # @return [Syntropy::App] applet
77
+ def builtin_applet(env, mount_path: '/.syntropy')
78
+ app(
79
+ machine: env[:machine],
80
+ app_root: BUILTIN_APPLET_app_root,
81
+ mount_path: mount_path,
82
+ builtin_applet_path: nil,
83
+ watch_files: nil
84
+ )
85
+ end
86
+
87
+ private
88
+
89
+ # Finds sites in the root directory for the given environment hash, adds
90
+ # entries to the given site map.
91
+ #
92
+ # @param dir [String] relative or absolute path
93
+ # @param site_map [Hash] site map
94
+ # @return [void]
95
+ def setup_directory_sites(ref, site_map)
96
+ app_root = @app ? @app.app_root : @env[:app_root]
97
+ ref = normalize_import_ref(ref)
98
+
99
+ Dir[File.join(app_root, ref, '*')]
100
+ .select { File.directory?(it) && File.basename(it) !~ /^_/ }
101
+ .each { |entry| site_map[File.basename(entry)] = make_app(entry) }
102
+ end
103
+
104
+ # converts the given map entries by adding entries to the given site map.
105
+ #
106
+ # @param map [Hash] ref map
107
+ # @param site_map [Hash] site map
108
+ # @return [void]
109
+ def setup_mapped_sites(map, site_map)
110
+ app_root = @app ? @app.app_root : @env[:app_root]
111
+ map.each do |name, ref|
112
+ ref = File.join(File.dirname(@ref), ref) if ref !~ /^\//
113
+ site_root = File.join(app_root, ref)
114
+ site_map[name] = make_app(site_root)
115
+ end
116
+ end
117
+
118
+ # Creates an app loaded from the given root directory, with the present
119
+ # mount path.
120
+ def make_app(site_root)
121
+ mount_path = @ref == '/_site' ? '/' : @ref
122
+ env = @env.merge(app_root: site_root, mount_path:)
123
+ Syntropy::App.new(**env)
124
+ end
125
+
126
+ # Handles the given request by calling the module method corresponding to
127
+ # the request's HTTP method. If no method is found, raises a
128
+ # method_not_allowed error.
129
+ def route_by_http_method(req)
130
+ sym = req.method.to_sym
131
+ raise Syntropy::Error.method_not_allowed if !respond_to?(sym)
132
+
133
+ send(sym, req)
134
+ end
135
+ end
136
+ end
@@ -76,6 +76,9 @@ module Syntropy
76
76
  def http_read_body(headers)
77
77
  content_length = headers['content-length']
78
78
  if content_length
79
+ content_length = content_length.to_i
80
+ return nil if content_length == 0
81
+
79
82
  chunk = read(content_length.to_i)
80
83
  return chunk
81
84
  end
@@ -95,6 +98,9 @@ module Syntropy
95
98
  def http_skip_body(headers)
96
99
  content_length = headers['content-length']
97
100
  if content_length
101
+ content_length = content_length.to_i
102
+ return if content_length == 0
103
+
98
104
  return skip(content_length.to_i)
99
105
  end
100
106
 
@@ -110,6 +116,9 @@ module Syntropy
110
116
  def http_read_body_chunk(headers)
111
117
  content_length = headers['content-length']
112
118
  if content_length
119
+ content_length = content_length.to_i
120
+ return nil if content_length == 0
121
+
113
122
  chunk = read(content_length.to_i)
114
123
  return chunk
115
124
  end
@@ -39,6 +39,7 @@ module Syntropy
39
39
  error: e
40
40
  )
41
41
  ensure
42
+ @io.clear
42
43
  @machine.close_async(@fd)
43
44
  end
44
45
 
@@ -25,6 +25,11 @@ module Syntropy
25
25
  ':status' => status,
26
26
  'Content-Type' => 'application/json'
27
27
  )
28
+ rescue => e
29
+ puts '*' * 40
30
+ p e
31
+ p e.backtrace.join
32
+ puts
28
33
  end
29
34
 
30
35
  private
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'papercraft'
4
+ require 'syntropy/errors'
4
5
 
5
6
  module Syntropy
6
7
  # The ModuleLoader class implemenets a module loader. It handles loading of
@@ -25,12 +26,14 @@ module Syntropy
25
26
  # Instantiates a module loader
26
27
  #
27
28
  # @param env [Hash] environment hash
29
+ # @param extensions [Module, Array<Module>] extension module(s)
28
30
  # @return [void]
29
- def initialize(env)
31
+ def initialize(env, extensions: nil)
30
32
  @env = env
31
33
  @app_root = env[:app_root]
32
34
  @modules = {} # maps ref to module entry
33
35
  @fn_map = {} # maps filename to ref
36
+ @extensions = extensions
34
37
  end
35
38
 
36
39
  # Loads a module (if not already loaded) and returns its export value.
@@ -131,7 +134,7 @@ module Syntropy
131
134
  @fn_map[fn] = ref
132
135
  code = IO.read(fn)
133
136
  env = @env.merge(module_loader: self, ref: clean_ref(ref))
134
- mod = Syntropy::Module.load(env, code, fn)
137
+ mod = Syntropy::ModuleContext.load(env, code, fn, @extensions)
135
138
  add_dependencies(ref, mod.__dependencies__)
136
139
  export_value = transform_module_export_value(
137
140
  mod.__export_value__, fn, raise_on_missing:
@@ -150,10 +153,10 @@ module Syntropy
150
153
  # @param ref [String] input ref
151
154
  # @return [String] clean ref
152
155
  def clean_ref(ref)
153
- return '/' if ref =~ /^index(\+)?$/
156
+ return '/' if ref =~ /^index[+]?$/
154
157
 
155
- clean = ref.gsub(/\/index(?:\+)?$/, '')
156
- clean == '' ? '/' : clean
158
+ clean = ref.gsub(/\/index[+]?$/, '')
159
+ (clean == '') ? '/' : clean
157
160
  end
158
161
 
159
162
  # Transforms the given export value. If the value is nil, an exception is
@@ -167,17 +170,15 @@ module Syntropy
167
170
  raise Syntropy::Error, "No export found in #{fn}" if raise_on_missing
168
171
  when String
169
172
  ->(req) { req.respond(export_value) }
170
- when Class
171
- export_value.new(@env)
172
173
  else
173
174
  export_value
174
175
  end
175
176
  end
176
177
  end
177
178
 
178
- # The Syntropy::Module class implements a reloadable module. A module is a
179
- # `.rb` source file that implements a route endpoint, a template, utility
180
- # methods or any other functionality needed by the web app.
179
+ # The Syntropy::ModuleContext class provides a context for loading a module. A
180
+ # module is a `.rb` source file that implements a route endpoint, a template,
181
+ # utility methods or any other functionality needed by the web app.
181
182
  #
182
183
  # The following instance variables are available to modules:
183
184
  #
@@ -191,35 +192,45 @@ module Syntropy
191
192
  # In addition, the module code also has access to the `MODULE` constant which
192
193
  # is set to `self`, and may be used to refer to various methods defined in the
193
194
  # module.
194
- class Module
195
+ class ModuleContext
195
196
  # Loads a module, returning the module instance
196
- def self.load(env, code, fn)
197
- m = new(**env)
198
- m.instance_eval(code, fn)
197
+ # @param env [Hash] app environment
198
+ # @param code [String] module source code
199
+ # @param fn [String] module file name
200
+ # @param extensions [Module, Array<Module>] extension module(s)
201
+ # @return [Syntropy::ModuleContext] created module context
202
+ def self.load(env, code, fn, extensions)
203
+ mod = new(env)
204
+ apply_extensions(mod, extensions)
205
+ mod.instance_eval(code, fn)
199
206
  env[:logger]&.info(message: "Loaded module at #{fn}")
200
- m
201
- rescue SyntaxError => e
207
+ mod
208
+ rescue StandardError, SyntaxError => e
202
209
  env[:logger]&.error(message: "Error while loading module at #{fn}", error: e)
203
- STDERR.puts("\n#{e.message}") if !Syntropy.test_mode
210
+ e.is_a?(SyntaxError) ? handle_syntax_error(env, e) : (raise e)
211
+ end
204
212
 
205
- if (m = e.message.match(/^(.+)\: syntax/))
206
- location = m[1]
207
- e2 = SyntaxError.new("Syntax errors found in module #{env[:ref]}")
208
- e2.set_backtrace([location] + e.backtrace)
209
- raise e2
213
+ # Applies the given extension(s) to the given module context.
214
+ #
215
+ # @param mod [Syntropy::ModuleContext] module context
216
+ # @param extensions [Module, Array<Module>] extension module(s)
217
+ def self.apply_extensions(mod, extensions)
218
+ case extensions
219
+ when Array
220
+ extensions.each { mod.extend(it) }
221
+ when Module
222
+ mod.extend(extensions)
223
+ when nil # return
210
224
  else
211
- raise e
225
+ raise Syntropy::Error, "Invalid module extensions: #{extensions.inspect}"
212
226
  end
213
- rescue => e
214
- env[:logger]&.error(message: "Error while loading module at #{fn}", error: e)
215
- raise e
216
227
  end
217
228
 
218
229
  # Initializes a module with the given environment hash.
219
230
  #
220
231
  # @param env [Hash] environment hash
221
232
  # @return [void]
222
- def initialize(**env)
233
+ def initialize(env)
223
234
  @env = env
224
235
  @machine = env[:machine]
225
236
  @module_loader = env[:module_loader]
@@ -331,22 +342,15 @@ module Syntropy
331
342
  Syntropy::App.new(**env)
332
343
  end
333
344
 
334
- # Returns a request handler that handles requests by calling the appropriate
335
- # module method (e.g. get, post, etc.)
336
- #
337
- # @return [Proc]
338
- def http_methods
339
- ->(req) { route_by_http_method(req) }
340
- end
341
-
342
- # Handles the given request by calling the module method corresponding to
343
- # the request's HTTP method. If no method is found, raises a
344
- # method_not_allowed error.
345
- def route_by_http_method(req)
346
- sym = req.method.to_sym
347
- raise Syntropy::Error.method_not_allowed if !respond_to?(sym)
345
+ def handle_syntax_error(env, e)
346
+ $stderr.puts("\n#{e.message}") if !Syntropy.test_mode
347
+ m = e.message.match(/^(.+): syntax/)
348
+ raise e if !m
348
349
 
349
- send(sym, req)
350
+ location = m[1]
351
+ e2 = SyntaxError.new("Syntax errors found in module #{env[:ref]}")
352
+ e2.set_backtrace([location] + e.backtrace)
353
+ raise e2
350
354
  end
351
355
  end
352
356
  end