syntropy 0.30.0 → 0.32.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +30 -0
  4. data/TODO.md +46 -1
  5. data/bin/syntropy +8 -86
  6. data/cmd/_banner.rb +16 -0
  7. data/cmd/console.rb +77 -0
  8. data/cmd/help.rb +12 -0
  9. data/cmd/serve.rb +95 -0
  10. data/cmd/test.rb +40 -0
  11. data/examples/{counter.rb → basic/counter.rb} +1 -1
  12. data/examples/{templates.rb → basic/templates.rb} +1 -1
  13. data/examples/blog/app/_layout/default.rb +11 -0
  14. data/examples/blog/app/_lib/post_store.rb +47 -0
  15. data/examples/blog/app/_schema/2026-01-01-initial.rb +9 -0
  16. data/examples/blog/app/_setup.rb +4 -0
  17. data/examples/blog/app/index.rb +7 -0
  18. data/examples/blog/app/posts/[id]/edit.rb +33 -0
  19. data/examples/blog/app/posts/[id]/index.rb +58 -0
  20. data/examples/blog/app/posts/index.rb +38 -0
  21. data/examples/blog/app/posts/new.rb +29 -0
  22. data/examples/mcp-oauth/.ruby-version +1 -0
  23. data/examples/mcp-oauth/Gemfile +8 -0
  24. data/examples/mcp-oauth/README.md +128 -0
  25. data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
  26. data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
  27. data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
  28. data/examples/mcp-oauth/app/index.md +1 -0
  29. data/examples/mcp-oauth/app/mcp.rb +85 -0
  30. data/examples/mcp-oauth/app/oauth/authorize.rb +18 -0
  31. data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
  32. data/examples/mcp-oauth/app/oauth/register.rb +14 -0
  33. data/examples/mcp-oauth/app/oauth/token.rb +79 -0
  34. data/examples/mcp-oauth/app/signin.rb +85 -0
  35. data/examples/mcp-oauth/test/helper.rb +9 -0
  36. data/examples/mcp-oauth/test/test_app.rb +27 -0
  37. data/examples/mcp-oauth/test/test_oauth.rb +628 -0
  38. data/lib/syntropy/app.rb +34 -9
  39. data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
  40. data/lib/syntropy/applets/builtin/req.rb +1 -1
  41. data/lib/syntropy/db/connection_pool.rb +71 -0
  42. data/lib/syntropy/db/schema.rb +92 -0
  43. data/lib/syntropy/db/store.rb +31 -0
  44. data/lib/syntropy/dev_mode.rb +1 -1
  45. data/lib/syntropy/errors.rb +6 -0
  46. data/lib/syntropy/http/client.rb +43 -0
  47. data/lib/syntropy/http/client_connection.rb +36 -0
  48. data/lib/syntropy/http/io_extensions.rb +176 -0
  49. data/lib/syntropy/http/server.rb +5 -5
  50. data/lib/syntropy/http/{connection.rb → server_connection.rb} +15 -91
  51. data/lib/syntropy/http.rb +3 -1
  52. data/lib/syntropy/logger.rb +5 -1
  53. data/lib/syntropy/{module.rb → module_loader.rb} +47 -8
  54. data/lib/syntropy/papercraft_extensions.rb +1 -1
  55. data/lib/syntropy/request/mock_adapter.rb +2 -0
  56. data/lib/syntropy/request/request_info.rb +22 -4
  57. data/lib/syntropy/request/response.rb +2 -2
  58. data/lib/syntropy/request/validation.rb +11 -5
  59. data/lib/syntropy/routing_tree.rb +2 -1
  60. data/lib/syntropy/test.rb +77 -0
  61. data/lib/syntropy/version.rb +1 -1
  62. data/lib/syntropy.rb +5 -23
  63. data/syntropy.gemspec +3 -3
  64. data/test/app/.well-known/foo.rb +3 -0
  65. data/test/app/_hook.rb +1 -1
  66. data/test/app/by_method.rb +9 -0
  67. data/test/app_setup/_setup.rb +7 -0
  68. data/test/app_setup/index.rb +1 -0
  69. data/test/app_with_schema/_schema/2026-01-02-foo.rb +12 -0
  70. data/test/app_with_schema/_schema/2026-05-30-bar.rb +7 -0
  71. data/test/helper.rb +1 -25
  72. data/test/schema/2026-01-02-foo.rb +12 -0
  73. data/test/schema/2026-05-30-bar.rb +7 -0
  74. data/test/test_app.rb +110 -70
  75. data/test/test_caching.rb +1 -1
  76. data/test/{test_connection_pool.rb → test_db_connection_pool.rb} +7 -2
  77. data/test/test_db_schema.rb +96 -0
  78. data/test/test_db_store.rb +24 -0
  79. data/test/test_http_client.rb +52 -0
  80. data/test/test_http_client_connection.rb +43 -0
  81. data/test/test_http_protocol.rb +250 -0
  82. data/test/{test_connection.rb → test_http_server_connection.rb} +39 -48
  83. data/test/test_json_api.rb +5 -5
  84. data/test/{test_module.rb → test_module_loader.rb} +31 -0
  85. data/test/{test_request_extensions.rb → test_request.rb} +153 -18
  86. data/test/test_routing_tree.rb +15 -3
  87. data/test/test_server.rb +9 -13
  88. metadata +84 -36
  89. data/lib/syntropy/connection_pool.rb +0 -61
  90. data/test/test_request_info.rb +0 -90
  91. /data/examples/{bad.rb → basic/bad.rb} +0 -0
  92. /data/examples/{card.rb → basic/card.rb} +0 -0
  93. /data/examples/{counter.js → basic/counter.js} +0 -0
  94. /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
  95. /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
  96. /data/examples/{index.md → basic/index.md} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70dae64fe246aa0851a582ff914d647bcfc650755a237ef476a37de8fd0cc052
4
- data.tar.gz: 9fde1a73c1136b316f4a5a42d7d0e359df76f4a7015146a5e4c0583eb22c9f76
3
+ metadata.gz: fbf52e43aad7bcf2dc6259fd40dac6fb6f70d2215513ae89a5e72b120c4b8585
4
+ data.tar.gz: 7a7fd31a1e47bf999d64ad3216bb2c77345a4eb3753df6e2a51384870d9f764f
5
5
  SHA512:
6
- metadata.gz: 2a5561fd30d062766fbb5e5df5ab80b1a6840b5a0bb29c2b1bc0e9a3a7872462face8a9e908d72f1a5426f5ad781b91adc079ca2412efbe95071b68f76c030d6
7
- data.tar.gz: 22708dd2c98a250068613cbc1d54157ec5673100d2d4df2df216fa353085cbc13c696ae3903a527926c03c17ba746a145c1ff70f0aefddf7fd8996d60527d263
6
+ metadata.gz: ff3ac2fbb77785db855080dcd0302beb61332c067c30db6330540cda9a60a7ab72ad684b87772951e8498d5c6aa10b2cf57cce7f1a85932f95db889432c704a0
7
+ data.tar.gz: 84ee4aa581f46c84999109bb102d9c890f9dc45b2803a52f114357981edf9150b84c187ba3a62670691e3eb8629b6396bd8f1789f882857b1558089c198f2877
data/.gitignore CHANGED
@@ -54,3 +54,5 @@ Gemfile.lock
54
54
 
55
55
  # Used by RuboCop. Remote config files pulled in from inherit_from directive.
56
56
  # .rubocop-https?--*
57
+
58
+ examples/**/*.db*
data/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
1
+ # 0.32.0 2026-06-01
2
+
3
+ - Ensure HTTP request body is consumed (skipped) before treating next request
4
+ - Fix `Request#auth_bearer_token`
5
+ - Add `syntropy console` CLI command
6
+ - Add `Module#http_methods` method for simpler REST controllers
7
+ - Add `App#setup_db` method
8
+ - Add `DB::Schema` for schema migrations with support for migration modules
9
+ - Add `DB::Store` class
10
+ - Rename `ConnectionPool` to `DB::ConnectionPool`
11
+ - Add support for setup file (`_setup.rb`)
12
+ - Remove escape_utils dependency
13
+
14
+ # 0.31.0 2026-05-29
15
+
16
+ - Add message kwarg to `Request#validate`
17
+ - Reraise internal server error in test mode
18
+ - Use strings rather than symbols for query keys
19
+ - Add content type validation
20
+ - Add syntropy CLI commands: serve, test, help
21
+ - Rename `#auto_refresh_watch!` to `#auto_refresh!`
22
+ - Use `zed` instead of `vscode` scheme for editor links (in HTML-rendered error
23
+ backtraces)
24
+ - Implement basic `HTTP::Client` API
25
+ - Rename `#json_response` to `#respond_json`, `#html_response` to
26
+ `#respond_html`
27
+ - Rename `HTTP::Connection` to `HTTP::ServerConnection`
28
+ - Refactor HTTP protocol into UM::IO extensions
29
+ - Fix routing for files/dirs starting with . (e.g. /.well-known/*)
30
+
1
31
  # 0.30.0 2026-05-10
2
32
 
3
33
  - Refactor HTTP modules
data/TODO.md CHANGED
@@ -1,5 +1,44 @@
1
1
  ## Immediate
2
2
 
3
+ - [ ] Session
4
+
5
+ https://guides.rubyonrails.org/action_controller_overview.html#session
6
+
7
+ We want something that offers the same features as in Ruby on Rails. A storage
8
+ space for session metadata which can include a user_id, flash messages etc.
9
+
10
+ - The session is attached to the request, and is valid for the browser
11
+ session.
12
+ - The session is a KV store. It can be used to store any data relevant to the
13
+ user's browser session.
14
+ - Each session has a unique ID and that ID is passed to the browser as a
15
+ non-persistent cookie.
16
+ - A session expires if not used (e.g. after 7 days)
17
+ - Session storage either in memory or in DB
18
+ - The session is generated (and session cookie set) upon first write to
19
+ session.
20
+ - Session `Set-Cookie` header should be injected into the HTTP response.
21
+ - The entire session info can be stored in a cookie, provided it does not
22
+ exceed 4KB.
23
+
24
+ ```ruby
25
+ req.session[:flash] = 'Title cannot be empty!'
26
+ req.redirect '/blah'
27
+ ```
28
+
29
+ - [ ] Flash messages
30
+
31
+ - Flash messages are a sub-feature of session storage and are used to relay
32
+ messages from one request to the next in the same session.
33
+ - Flash messages have more complex semantics:
34
+ - There can be more than one.
35
+ - They have types - notice, alert, etc.
36
+ - They normally disappear on the next request (i.e. the session cookie
37
+ should be updated in the response with Set-Cookie).
38
+ - But, you can keep them for the next request with `req.session.flash.keep`
39
+ - The flash messages could be used for the current request with
40
+ `req.session.flash.now`
41
+
3
42
  - [ ] Collection - treat directories and files as collections of data.
4
43
 
5
44
  Kind of similar to the routing tree, but instead of routes it just takes a
@@ -39,9 +78,15 @@
39
78
  - [ ] Website
40
79
  - [v] Frontend part of JSON API
41
80
  - [v] Auto-refresh page when file changes
81
+ - [ ] SQLite database capabilities
82
+ - [ ] Model API + tools
83
+ - [ ] Do we need/want migrations?
84
+ - [ ] Stores
85
+ - [ ] KV store (with TTL)
42
86
  - [v] Examples
43
87
  - [v] Reactive app - counter or some other simple app showing interaction with
44
88
  server
89
+ - [ ] blog
45
90
 
46
91
  ## Testing facilities
47
92
 
@@ -49,7 +94,7 @@
49
94
  - Routes
50
95
  - Route responses
51
96
  - Changes to state / DB
52
- -
97
+ - Rendered HTML - presence of certain markup / elements / text
53
98
 
54
99
  ## Support for applets
55
100
 
data/bin/syntropy CHANGED
@@ -1,93 +1,15 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require 'syntropy'
5
- require 'optparse'
4
+ require_relative '../cmd/_banner'
6
5
 
7
- env = {
8
- mount_path: '/',
9
- banner: Syntropy::BANNER,
10
- logger: true,
11
- builtin_applet_path: '/.syntropy',
12
- server_extensions: {
13
- date: true,
14
- name: 'Syntropy'
15
- }
16
- }
6
+ def cmd_fn(cmd)= File.join(__dir__, "../cmd/#{cmd}.rb")
17
7
 
18
- parser = OptionParser.new do |o|
19
- o.banner = 'Usage: syntropy [options] DIR'
8
+ cmd = ARGV.shift || 'help'
9
+ cmd = 'help' if cmd !~ /^[a-z]+$/
20
10
 
21
- o.on('-b', '--bind BIND', String,
22
- 'Bind address (default: http://0.0.0.0:1234). You can specify this flag multiple times to bind to multiple addresses.') do
23
- env[:bind] ||= []
24
- env[:bind] << it
25
- end
11
+ fn = cmd_fn(cmd)
12
+ fn = cmd_fn('help') if !File.file?(fn)
26
13
 
27
- o.on('-s', '--silent', 'Silent mode') do
28
- env[:banner] = nil
29
- env[:logger] = nil
30
- end
31
-
32
- o.on('-d', '--dev', 'Development mode') do
33
- env[:dev_mode] = true
34
- env[:watch_files] = 0.1
35
- end
36
-
37
- o.on('-h', '--help', 'Show this help message') do
38
- puts o
39
- exit
40
- end
41
-
42
- o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
43
- p mount: path
44
- env[:mount_path] = path
45
- env[:builtin_applet_path] = File.join(path, '.syntropy')
46
- end
47
-
48
- o.on('--no-builtin-applet', 'Do not mount builtin applet') do
49
- env[:builtin_applet_path] = nil
50
- end
51
-
52
- o.on('--no-server-headers', 'Don\'t include Server and Date headers') do
53
- env[:server_extensions] = nil
54
- end
55
-
56
- o.on('-v', '--version', 'Show version') do
57
- require 'syntropy/version'
58
- puts "Syntropy version #{Syntropy::VERSION}"
59
- exit
60
- end
61
- end
62
-
63
- RubyVM::YJIT.enable rescue nil
64
-
65
- begin
66
- parser.parse!
67
- rescue StandardError => e
68
- puts e.message
69
- puts e.backtrace.join("\n")
70
- exit
71
- end
72
-
73
- $syntropy_dev_mode = env[:dev_mode]
74
- env[:root_dir] = (ARGV.shift || '.').gsub(/\/$/, '')
75
-
76
- if !File.directory?(env[:root_dir])
77
- puts "#{File.expand_path(env[:root_dir])} Not a directory"
78
- exit
79
- end
80
-
81
- puts env[:banner] if env[:banner]
82
- env[:banner] = false
83
-
84
-
85
- # We set Syntropy.machine so we can reference it from anywhere
86
- env[:machine] = Syntropy.machine = UM.new
87
- env[:logger] = env[:logger] && Syntropy::Logger.new(env[:machine], **env)
88
-
89
- require 'syntropy/version'
90
- require 'syntropy/dev_mode' if env[:dev_mode]
91
-
92
- app = Syntropy::App.load(env)
93
- Syntropy.run(env) { app.call(it) }
14
+ puts SYNTROPY_BANNER
15
+ require(fn)
data/cmd/_banner.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ green = "\e[32m"
4
+ clear = "\e[0m"
5
+ yellow = "\e[33m"
6
+
7
+ SYNTROPY_BANNER =
8
+ "\n"\
9
+ " #{green}\n"\
10
+ " #{green} ooo\n"\
11
+ " #{green}ooooo\n"\
12
+ " #{green} ooo vvv #{clear}Syntropy - a web framework for Ruby\n"\
13
+ " #{green} o vvvvv #{clear}--------------------------------------\n"\
14
+ " #{green} #{yellow}|#{green} vvv o #{clear}https://github.com/digital-fabric/syntropy\n"\
15
+ " #{green} :#{yellow}|#{green}:::#{yellow}|#{green}::#{yellow}|#{green}:\n"\
16
+ "#{yellow}++++++++++++++++++++++++++++++++++++++++++++++++++++++++++#{clear}\n\n"
data/cmd/console.rb ADDED
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../lib/syntropy'
4
+ require 'optparse'
5
+
6
+ env = {
7
+ mount_path: '/',
8
+ logger: true,
9
+ builtin_applet_path: '/.syntropy',
10
+ watch_files: true
11
+ }
12
+
13
+ parser = OptionParser.new do |o|
14
+ o.banner = 'Usage: syntropy serve [options] DIR'
15
+
16
+ o.on('-h', '--help', 'Show this help message') do
17
+ puts o
18
+ exit
19
+ end
20
+
21
+ o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
22
+ p mount: path
23
+ env[:mount_path] = path
24
+ env[:builtin_applet_path] = File.join(path, '.syntropy')
25
+ end
26
+
27
+ o.on('--no-builtin-applet', 'Do not mount builtin applet') do
28
+ env[:builtin_applet_path] = nil
29
+ end
30
+ end
31
+
32
+ RubyVM::YJIT.enable rescue nil
33
+
34
+ begin
35
+ parser.parse!
36
+ rescue OptionParser::InvalidOption
37
+ puts parser
38
+ exit
39
+ rescue StandardError => e
40
+ p e
41
+ puts e.message
42
+ puts e.backtrace.join("\n")
43
+ exit
44
+ end
45
+
46
+ $syntropy_dev_mode = env[:dev_mode]
47
+ env[:root_dir] = (ARGV.shift || '.').gsub(/\/$/, '')
48
+
49
+ if !File.directory?(env[:root_dir])
50
+ puts "#{File.expand_path(env[:root_dir])} Not a directory"
51
+ exit
52
+ end
53
+
54
+ puts env[:banner] if env[:banner]
55
+ env[:banner] = false
56
+
57
+ # We set Syntropy.machine so we can reference it from anywhere
58
+ env[:machine] = Syntropy.machine = UM.new
59
+ env[:logger] = env[:logger] && Syntropy::Logger.new(env[:machine], **env)
60
+
61
+ @app = Syntropy::App.load(env)
62
+ @env = env
63
+ @machine = env[:machine]
64
+ @connection_pool = @app.connection_pool if @app.respond_to?(:connection_pool)
65
+ @schema = @app.schema if @app.respond_to?(:schema)
66
+ @module_loader = @app.module_loader
67
+
68
+ require 'uringmachine/fiber_scheduler'
69
+ @scheduler = UM::FiberScheduler.new(@machine)
70
+ Fiber.set_scheduler(@scheduler)
71
+
72
+ def import(ref)
73
+ @module_loader.load(ref)
74
+ end
75
+
76
+ require 'irb'
77
+ IRB.start
data/cmd/help.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ HELP = <<~MSG
4
+ Usage: syntropy COMMAND [options]
5
+
6
+ Available commands:
7
+
8
+ serve Start a Syntropy server
9
+ test Run tests
10
+ MSG
11
+
12
+ $stdout << HELP
data/cmd/serve.rb ADDED
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../lib/syntropy'
4
+ require 'optparse'
5
+
6
+ env = {
7
+ mount_path: '/',
8
+ logger: true,
9
+ builtin_applet_path: '/.syntropy',
10
+ server_extensions: {
11
+ date: true,
12
+ name: 'Syntropy'
13
+ }
14
+ }
15
+
16
+ parser = OptionParser.new do |o|
17
+ o.banner = 'Usage: syntropy serve [options] DIR'
18
+
19
+ o.on('-b', '--bind BIND', String,
20
+ 'Bind address (default: http://0.0.0.0:1234). You can specify this flag multiple times to bind to multiple addresses.') do
21
+ env[:bind] ||= []
22
+ env[:bind] << it
23
+ end
24
+
25
+ o.on('-s', '--silent', 'Silent mode') do
26
+ env[:banner] = nil
27
+ env[:logger] = nil
28
+ end
29
+
30
+ o.on('-d', '--dev', 'Development mode') do
31
+ env[:dev_mode] = true
32
+ env[:watch_files] = true
33
+ end
34
+
35
+ o.on('-h', '--help', 'Show this help message') do
36
+ puts o
37
+ exit
38
+ end
39
+
40
+ o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
41
+ p mount: path
42
+ env[:mount_path] = path
43
+ env[:builtin_applet_path] = File.join(path, '.syntropy')
44
+ end
45
+
46
+ o.on('--no-builtin-applet', 'Do not mount builtin applet') do
47
+ env[:builtin_applet_path] = nil
48
+ end
49
+
50
+ o.on('--no-server-headers', 'Don\'t include Server and Date headers') do
51
+ env[:server_extensions] = nil
52
+ end
53
+
54
+ o.on('-v', '--version', 'Show version') do
55
+ require 'syntropy/version'
56
+ puts "Syntropy version #{Syntropy::VERSION}"
57
+ exit
58
+ end
59
+ end
60
+
61
+ RubyVM::YJIT.enable rescue nil
62
+
63
+ begin
64
+ parser.parse!
65
+ rescue OptionParser::InvalidOption
66
+ puts parser
67
+ exit
68
+ rescue StandardError => e
69
+ p e
70
+ puts e.message
71
+ puts e.backtrace.join("\n")
72
+ exit
73
+ end
74
+
75
+ $syntropy_dev_mode = env[:dev_mode]
76
+ env[:root_dir] = (ARGV.shift || '.').gsub(/\/$/, '')
77
+
78
+ if !File.directory?(env[:root_dir])
79
+ puts "#{File.expand_path(env[:root_dir])} Not a directory"
80
+ exit
81
+ end
82
+
83
+ puts env[:banner] if env[:banner]
84
+ env[:banner] = false
85
+
86
+
87
+ # We set Syntropy.machine so we can reference it from anywhere
88
+ env[:machine] = Syntropy.machine = UM.new
89
+ env[:logger] = env[:logger] && Syntropy::Logger.new(env[:machine], **env)
90
+
91
+ require 'syntropy/version'
92
+ require 'syntropy/dev_mode' if env[:dev_mode]
93
+
94
+ app = Syntropy::App.load(env)
95
+ Syntropy.run(env) { app.call(it) }
data/cmd/test.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ env = {}
4
+ argv_copy = ARGV.dup
5
+ case ARGV[0]
6
+ when '-w', '--watch'
7
+ env[:watch_mode] = true
8
+ ARGV.shift
9
+ when '-h', '--help'
10
+ puts <<~MSG
11
+ Usage: syntropy test [options] [minitest options]
12
+ -w, --watch Rerun tests on file system changes
13
+ -h, --help Show this help message
14
+ MSG
15
+ exit
16
+ end
17
+
18
+ require_relative '../lib/syntropy'
19
+ require_relative '../lib/syntropy/test'
20
+
21
+ $stdout.sync = true
22
+ $stderr.sync = true
23
+
24
+ Dir.glob("./test/test_*.rb").each { require(it) }
25
+
26
+ def watch_for_file_changes
27
+ m = UM.new
28
+ puts "Waiting for file changes in #{FileUtils.pwd}"
29
+ m.file_watch(FileUtils.pwd, UM::IN_CREATE | UM::IN_DELETE | UM::IN_CLOSE_WRITE) {
30
+ puts "Detected changes to #{it[:fn]}, restarting"
31
+ break
32
+ }
33
+ end
34
+
35
+ Minitest.run ARGV
36
+ if env[:watch_mode]
37
+ puts
38
+ watch_for_file_changes
39
+ exec("ruby", __FILE__, *argv_copy)
40
+ end
@@ -18,6 +18,6 @@ export template {
18
18
  div { font-weight: bold; font-size: 1.3em }
19
19
  value { display: inline-block; padding: 0 1em; color: blue; width: 1em }
20
20
  CSS
21
- auto_refresh_watch!
21
+ auto_refresh!
22
22
  }
23
23
  }
@@ -32,7 +32,7 @@ export template {
32
32
 
33
33
  Card()
34
34
  }
35
- auto_refresh_watch!
35
+ auto_refresh!
36
36
  debug_template!
37
37
  }
38
38
  }
@@ -0,0 +1,11 @@
1
+ export template { |**props|
2
+ html {
3
+ head {
4
+ title "My awesome blog"
5
+ }
6
+ body {
7
+ render_children(**props)
8
+ auto_refresh!
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,47 @@
1
+ class PostStore < Syntropy::DB::Store
2
+ # @return [Integer] post id
3
+ def create(title, body)
4
+ query_single_value <<~SQL, title:, body:
5
+ insert into posts (title, body)
6
+ values (:title, :body)
7
+ returning id;
8
+ SQL
9
+ end
10
+
11
+ # @return [void]
12
+ def update(id, title, body)
13
+ execute <<~SQL, id:, title:, body:
14
+ update posts
15
+ set title = :title, body = :body
16
+ where id = :id
17
+ SQL
18
+ end
19
+
20
+ # @return [void]
21
+ def delete(id)
22
+ execute <<~SQL, id:
23
+ delete from posts
24
+ where id = :id
25
+ SQL
26
+ end
27
+
28
+ # return [Hash]
29
+ def get(id)
30
+ query_single_row <<~SQL, id:
31
+ select id, title, body
32
+ from posts
33
+ where id = :id
34
+ SQL
35
+ end
36
+
37
+ # return [Array<Hash>]
38
+ def get_all
39
+ query <<~SQL
40
+ select id, title, body
41
+ from posts
42
+ order by id desc
43
+ SQL
44
+ end
45
+ end
46
+
47
+ export PostStore.new(@app.connection_pool)
@@ -0,0 +1,9 @@
1
+ export ->(db) {
2
+ db.execute <<~SQL
3
+ create table posts (
4
+ id integer primary key autoincrement,
5
+ title text,
6
+ body text
7
+ )
8
+ SQL
9
+ }
@@ -0,0 +1,4 @@
1
+ @app.setup_db(
2
+ db_path: File.join(@app.root_dir, '../blog.db'),
3
+ schema_root: '_schema'
4
+ )
@@ -0,0 +1,7 @@
1
+ layout = import '_layout/default'
2
+
3
+ export layout.apply {
4
+ p {
5
+ a 'Blog posts', href: '/posts'
6
+ }
7
+ }
@@ -0,0 +1,33 @@
1
+ @post_store = import '/_lib/post_store'
2
+ @layout = import '/_layout/default'
3
+
4
+ export http_methods
5
+
6
+ def get(req)
7
+ id = req.route_params['id'].to_i
8
+ post = @post_store.get(id)
9
+ raise Syntropy::Error.not_found if !post
10
+
11
+ req.respond_html(
12
+ @template.render(post:)
13
+ )
14
+ end
15
+
16
+ @template = @layout.apply { |post:, **props|
17
+ h1 "Edit blog post"
18
+ div {
19
+ form(action: "/posts/#{post[:id]}", method: 'post') {
20
+ div {
21
+ label 'Title', for: 'title'
22
+ input name: 'title', type: 'text', value: post[:title]
23
+ }
24
+ div {
25
+ label 'Body', for: 'body'
26
+ textarea post[:body], name: 'body', rows: 5
27
+ }
28
+ div {
29
+ button 'Submit', type: 'submit'
30
+ }
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,58 @@
1
+ @post_store = import '/_lib/post_store'
2
+ @layout = import '/_layout/default'
3
+
4
+ export http_methods
5
+
6
+ def get(req)
7
+ id = req.route_params['id'].to_i
8
+ post = @post_store.get(id)
9
+ raise Syntropy::Error.not_found if !post
10
+
11
+ req.respond_html(
12
+ @template.render(post:)
13
+ )
14
+ end
15
+
16
+ def post(req)
17
+ data = req.get_form_data
18
+ return delete(req) if data['method'] == 'delete'
19
+
20
+ id = req.route_params['id'].to_i
21
+ title = req.validate(data['title'], String, /.+/)
22
+ body = req.validate(data['body'], String, /.+/)
23
+
24
+ updated = @post_store.update(id, title, body)
25
+ raise BadRequestError, "Failed to update post" if updated != 1
26
+
27
+ req.redirect "/posts/#{id}", Syntropy::HTTP::SEE_OTHER
28
+ end
29
+
30
+ def delete(req)
31
+ id = req.route_params['id'].to_i
32
+
33
+ deleted = @post_store.delete(id)
34
+ raise BadRequestError, "Failed to delete post" if deleted != 1
35
+
36
+ req.redirect "/posts", Syntropy::HTTP::SEE_OTHER
37
+ end
38
+
39
+ @template = @layout.apply { |post:, **props|
40
+ h1 "My blog"
41
+ div {
42
+ h2 {
43
+ a post[:title]
44
+ }
45
+ p post[:body]
46
+ }
47
+ p {
48
+ a "Edit", href: "/posts/#{post[:id]}/edit"
49
+ span '|'
50
+ a "Back to posts", href: '/posts'
51
+ }
52
+ div {
53
+ form(method: 'post') {
54
+ input type: 'hidden', name: 'method', value: 'delete'
55
+ button 'Delete this post', name: 'delete', type: 'submit'
56
+ }
57
+ }
58
+ }