syntropy 0.31.0 → 0.33.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/.gitignore +2 -0
- data/CHANGELOG.md +20 -0
- data/TODO.md +7 -1
- data/cmd/console.rb +77 -0
- data/cmd/serve.rb +1 -3
- data/cmd/test.rb +76 -20
- data/examples/blog/app/_layout/default.rb +11 -0
- data/examples/blog/app/_lib/post_store.rb +47 -0
- data/examples/blog/app/_schema/2026-01-01-initial.rb +9 -0
- data/examples/blog/app/_setup.rb +4 -0
- data/examples/blog/app/index.rb +7 -0
- data/examples/blog/app/posts/[id]/edit.rb +33 -0
- data/examples/blog/app/posts/[id]/index.rb +61 -0
- data/examples/blog/app/posts/index.rb +40 -0
- data/examples/blog/app/posts/new.rb +29 -0
- data/examples/mcp-oauth/README.md +3 -3
- data/examples/mcp-oauth/app/mcp.rb +55 -8
- data/examples/mcp-oauth/app/oauth/authorize.rb +0 -8
- data/examples/mcp-oauth/app/oauth/register.rb +0 -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 +23 -9
- data/lib/syntropy/db/connection_pool.rb +71 -0
- data/lib/syntropy/db/schema.rb +92 -0
- data/lib/syntropy/db/store.rb +31 -0
- data/lib/syntropy/http/io_extensions.rb +33 -5
- data/lib/syntropy/http/server_connection.rb +21 -62
- data/lib/syntropy/{module.rb → module_loader.rb} +48 -8
- data/lib/syntropy/request/request_info.rb +3 -4
- data/lib/syntropy/request/response.rb +2 -2
- data/lib/syntropy/request/session.rb +113 -0
- data/lib/syntropy/request/validation.rb +1 -2
- data/lib/syntropy/request.rb +9 -0
- data/lib/syntropy/test.rb +84 -1
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +4 -2
- data/syntropy.gemspec +3 -1
- data/test/app/_hook.rb +1 -1
- data/test/app/by_method.rb +9 -0
- data/test/app_setup/_setup.rb +7 -0
- data/test/app_setup/index.rb +1 -0
- data/test/app_with_schema/_schema/2026-01-02-foo.rb +12 -0
- data/test/app_with_schema/_schema/2026-05-30-bar.rb +7 -0
- data/test/schema/2026-01-02-foo.rb +12 -0
- data/test/schema/2026-05-30-bar.rb +7 -0
- data/test/test_app.rb +58 -3
- data/test/{test_connection_pool.rb → test_db_connection_pool.rb} +7 -2
- data/test/test_db_schema.rb +96 -0
- data/test/test_db_store.rb +24 -0
- data/test/test_http_protocol.rb +250 -0
- data/test/test_http_server_connection.rb +18 -24
- data/test/test_json_api.rb +1 -1
- data/test/{test_module.rb → test_module_loader.rb} +31 -0
- data/test/test_request.rb +7 -4
- data/test/test_request_session.rb +254 -0
- data/test/test_server.rb +9 -13
- metadata +63 -12
- data/examples/mcp-oauth/test/helper.rb +0 -9
- data/lib/syntropy/connection_pool.rb +0 -61
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a39afb5d39d1a1c9b95e2e242b9ce55144ba6923ad4b0addee3c6c005d314ead
|
|
4
|
+
data.tar.gz: 0b067b50a4f27381ba495a32d8c8e471e4a64374d6eee3c62d25fb30aaa10a24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9768274a2d09c8f4068625006660403e98655cfa7d7d742ed75f3ce0e31abfc0410f301d16d8d8910fcc94d7947008238a730b19b514342cbb67dac8669a4e76
|
|
7
|
+
data.tar.gz: 76a2d90674f7ebc34f8162e3435d81edb1ca56022ef7d490bcc6fcfa0041529f1ace321233451dd7df40867b05eb83327984634461f0986dd650db3e0913af6c
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
# 0.33.0 2026-06-02
|
|
2
|
+
|
|
3
|
+
- Fix `ModuleLoader` to load a module only once
|
|
4
|
+
- Improve testing tools, add `Syntropy::Test` class
|
|
5
|
+
- Implement `Request#session`, `Request#flash`
|
|
6
|
+
- Improve `Request#set_cookie`
|
|
7
|
+
|
|
8
|
+
# 0.32.0 2026-06-01
|
|
9
|
+
|
|
10
|
+
- Ensure HTTP request body is consumed (skipped) before treating next request
|
|
11
|
+
- Fix `Request#auth_bearer_token`
|
|
12
|
+
- Add `syntropy console` CLI command
|
|
13
|
+
- Add `Module#http_methods` method for simpler REST controllers
|
|
14
|
+
- Add `App#setup_db` method
|
|
15
|
+
- Add `DB::Schema` for schema migrations with support for migration modules
|
|
16
|
+
- Add `DB::Store` class
|
|
17
|
+
- Rename `ConnectionPool` to `DB::ConnectionPool`
|
|
18
|
+
- Add support for setup file (`_setup.rb`)
|
|
19
|
+
- Remove escape_utils dependency
|
|
20
|
+
|
|
1
21
|
# 0.31.0 2026-05-29
|
|
2
22
|
|
|
3
23
|
- Add message kwarg to `Request#validate`
|
data/TODO.md
CHANGED
|
@@ -39,9 +39,15 @@
|
|
|
39
39
|
- [ ] Website
|
|
40
40
|
- [v] Frontend part of JSON API
|
|
41
41
|
- [v] Auto-refresh page when file changes
|
|
42
|
+
- [ ] SQLite database capabilities
|
|
43
|
+
- [ ] Model API + tools
|
|
44
|
+
- [ ] Do we need/want migrations?
|
|
45
|
+
- [ ] Stores
|
|
46
|
+
- [ ] KV store (with TTL)
|
|
42
47
|
- [v] Examples
|
|
43
48
|
- [v] Reactive app - counter or some other simple app showing interaction with
|
|
44
49
|
server
|
|
50
|
+
- [ ] blog
|
|
45
51
|
|
|
46
52
|
## Testing facilities
|
|
47
53
|
|
|
@@ -49,7 +55,7 @@
|
|
|
49
55
|
- Routes
|
|
50
56
|
- Route responses
|
|
51
57
|
- Changes to state / DB
|
|
52
|
-
-
|
|
58
|
+
- Rendered HTML - presence of certain markup / elements / text
|
|
53
59
|
|
|
54
60
|
## Support for applets
|
|
55
61
|
|
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/serve.rb
CHANGED
|
@@ -29,7 +29,7 @@ parser = OptionParser.new do |o|
|
|
|
29
29
|
|
|
30
30
|
o.on('-d', '--dev', 'Development mode') do
|
|
31
31
|
env[:dev_mode] = true
|
|
32
|
-
env[:watch_files] =
|
|
32
|
+
env[:watch_files] = true
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
o.on('-h', '--help', 'Show this help message') do
|
|
@@ -38,7 +38,6 @@ parser = OptionParser.new do |o|
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
|
|
41
|
-
p mount: path
|
|
42
41
|
env[:mount_path] = path
|
|
43
42
|
env[:builtin_applet_path] = File.join(path, '.syntropy')
|
|
44
43
|
end
|
|
@@ -83,7 +82,6 @@ end
|
|
|
83
82
|
puts env[:banner] if env[:banner]
|
|
84
83
|
env[:banner] = false
|
|
85
84
|
|
|
86
|
-
|
|
87
85
|
# We set Syntropy.machine so we can reference it from anywhere
|
|
88
86
|
env[:machine] = Syntropy.machine = UM.new
|
|
89
87
|
env[:logger] = env[:logger] && Syntropy::Logger.new(env[:machine], **env)
|
data/cmd/test.rb
CHANGED
|
@@ -1,17 +1,70 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'optparse'
|
|
5
|
+
|
|
6
|
+
pwd = FileUtils.pwd
|
|
7
|
+
env = {
|
|
8
|
+
root_dir: File.join(pwd, 'app'),
|
|
9
|
+
test_dir: File.join(pwd, 'test'),
|
|
10
|
+
mount_path: '/'
|
|
11
|
+
}
|
|
12
|
+
MINITEST_ARGV = []
|
|
13
|
+
|
|
14
|
+
parser = OptionParser.new do |o|
|
|
15
|
+
o.banner = 'Usage: syntropy test [options]'
|
|
16
|
+
|
|
17
|
+
o.on('-h', '--help', 'Show this help message') do
|
|
18
|
+
puts o
|
|
19
|
+
exit
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
|
|
23
|
+
env[:mount_path] = path
|
|
24
|
+
env[:builtin_applet_path] = File.join(path, '.syntropy')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
o.on('-w', '--watch', 'Watch for file changes') do
|
|
28
|
+
env[:watch_mode] = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
o.on('-a', '--app PATH', 'Set app root (default: ./app)') do |path|
|
|
32
|
+
env[:root_dir] = path
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
o.on('-t', '--test PATH', 'Set test root (default: ./test)') do |path|
|
|
36
|
+
env[:test_dir] = path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
o.on('-n', '--name NAME', 'Specify test to run') do |name|
|
|
40
|
+
MINITEST_ARGV << '--name' << name
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
o.on('-V', '--verbose', 'Verbose test output') do
|
|
44
|
+
MINITEST_ARGV << '--verbose'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
o.on('-s', '--seed SEED', 'Specify random seed') do |seed|
|
|
48
|
+
MINITEST_ARGV << '--seed' << seed
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
o.on('-v', '--version', 'Show version') do
|
|
52
|
+
require 'syntropy/version'
|
|
53
|
+
puts "Syntropy version #{Syntropy::VERSION}"
|
|
54
|
+
exit
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
4
58
|
argv_copy = ARGV.dup
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
MSG
|
|
59
|
+
begin
|
|
60
|
+
parser.parse!
|
|
61
|
+
rescue OptionParser::InvalidOption
|
|
62
|
+
puts parser
|
|
63
|
+
exit
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
p e
|
|
66
|
+
puts e.message
|
|
67
|
+
puts e.backtrace.join("\n")
|
|
15
68
|
exit
|
|
16
69
|
end
|
|
17
70
|
|
|
@@ -21,20 +74,23 @@ require_relative '../lib/syntropy/test'
|
|
|
21
74
|
$stdout.sync = true
|
|
22
75
|
$stderr.sync = true
|
|
23
76
|
|
|
24
|
-
Dir.glob("
|
|
77
|
+
Dir.glob("#{env[:test_dir]}/test_*.rb").each { require(it) }
|
|
25
78
|
|
|
26
|
-
def watch_for_file_changes
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
puts "Detected changes to #{it[:fn]}, restarting"
|
|
79
|
+
def watch_for_file_changes(machine)
|
|
80
|
+
machine.write(UM::STDOUT_FILENO, "Waiting for file changes in #{FileUtils.pwd}\n")
|
|
81
|
+
machine.file_watch(FileUtils.pwd, UM::IN_CREATE | UM::IN_DELETE | UM::IN_CLOSE_WRITE) {
|
|
82
|
+
machine.write(UM::STDOUT_FILENO, "File changed: #{it[:fn]}\n")
|
|
31
83
|
break
|
|
32
84
|
}
|
|
33
85
|
end
|
|
34
86
|
|
|
35
|
-
|
|
87
|
+
Syntropy::Test.env=(env)
|
|
88
|
+
Minitest.run MINITEST_ARGV
|
|
89
|
+
|
|
36
90
|
if env[:watch_mode]
|
|
37
|
-
|
|
38
|
-
|
|
91
|
+
m = UM.new(size: 4)
|
|
92
|
+
m.write(UM::STDOUT_FILENO, "\n")
|
|
93
|
+
trap('SIGINT') { m.write(UM::STDOUT_FILENO, "\n"); exit! }
|
|
94
|
+
watch_for_file_changes(m)
|
|
39
95
|
exec("ruby", __FILE__, *argv_copy)
|
|
40
96
|
end
|
|
@@ -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,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,61 @@
|
|
|
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:, req:)
|
|
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.flash[:notice] = 'Post was successfully updated.'
|
|
28
|
+
req.redirect "/posts/#{id}", Syntropy::HTTP::SEE_OTHER
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def delete(req)
|
|
32
|
+
id = req.route_params['id'].to_i
|
|
33
|
+
|
|
34
|
+
deleted = @post_store.delete(id)
|
|
35
|
+
raise BadRequestError, "Failed to delete post" if deleted != 1
|
|
36
|
+
|
|
37
|
+
req.flash[:notice] = 'Post was successfully destroyed.'
|
|
38
|
+
req.redirect "/posts", Syntropy::HTTP::SEE_OTHER
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@template = @layout.apply { |post:, **props|
|
|
42
|
+
h1 "My blog"
|
|
43
|
+
p props[:req]&.flash[:notice], style: 'color: green'
|
|
44
|
+
div {
|
|
45
|
+
h2 {
|
|
46
|
+
a post[:title]
|
|
47
|
+
}
|
|
48
|
+
p post[:body]
|
|
49
|
+
}
|
|
50
|
+
p {
|
|
51
|
+
a "Edit", href: "/posts/#{post[:id]}/edit"
|
|
52
|
+
span '|'
|
|
53
|
+
a "Back to posts", href: '/posts'
|
|
54
|
+
}
|
|
55
|
+
div {
|
|
56
|
+
form(method: 'post') {
|
|
57
|
+
input type: 'hidden', name: 'method', value: 'delete'
|
|
58
|
+
button 'Delete this post', name: 'delete', type: 'submit'
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
@post_store = import '_lib/post_store'
|
|
2
|
+
@layout = import '_layout/default'
|
|
3
|
+
|
|
4
|
+
export http_methods
|
|
5
|
+
|
|
6
|
+
def get(req)
|
|
7
|
+
posts = @post_store.get_all
|
|
8
|
+
req.respond_html(
|
|
9
|
+
@template.render(posts:, req:)
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def post(req)
|
|
14
|
+
data = req.get_form_data
|
|
15
|
+
title = req.validate(data['title'], String, /.+/)
|
|
16
|
+
body = req.validate(data['body'], String, /.+/)
|
|
17
|
+
id = @post_store.create(title, body)
|
|
18
|
+
|
|
19
|
+
req.flash[:notice] = 'Post was successfully created.'
|
|
20
|
+
req.redirect("posts/#{id}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@template = @layout.apply { |**props|
|
|
24
|
+
h1 "My blog"
|
|
25
|
+
p props[:req]&.flash[:notice], style: 'color: green'
|
|
26
|
+
props[:posts].each { |post|
|
|
27
|
+
div {
|
|
28
|
+
h2 {
|
|
29
|
+
a post[:title], href: "/posts/#{post[:id]}"
|
|
30
|
+
}
|
|
31
|
+
p post[:body]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
div {
|
|
36
|
+
p {
|
|
37
|
+
a "New post", href: '/posts/new'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
@post_store = import '/_lib/post_store'
|
|
2
|
+
@layout = import '/_layout/default'
|
|
3
|
+
|
|
4
|
+
export http_methods
|
|
5
|
+
|
|
6
|
+
def get(req)
|
|
7
|
+
req.respond_html(
|
|
8
|
+
@template.render
|
|
9
|
+
)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
@template = @layout.apply { |**props|
|
|
13
|
+
h1 "Create blog post"
|
|
14
|
+
div {
|
|
15
|
+
form(action: "/posts", method: 'post') {
|
|
16
|
+
div {
|
|
17
|
+
label 'Title', for: 'title'
|
|
18
|
+
input name: 'title', type: 'text'
|
|
19
|
+
}
|
|
20
|
+
div {
|
|
21
|
+
label 'Body', for: 'body'
|
|
22
|
+
textarea '', name: 'body', rows: 5
|
|
23
|
+
}
|
|
24
|
+
div {
|
|
25
|
+
button 'Submit', type: 'submit'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -69,7 +69,7 @@ This app implements a site that includes an MCP server with OAuth 2.1 authorizat
|
|
|
69
69
|
```
|
|
70
70
|
HTTP/1.1 201 Created
|
|
71
71
|
Content-Type: application/json
|
|
72
|
-
|
|
72
|
+
|
|
73
73
|
{
|
|
74
74
|
"client_id": "mcp_client_xyz789",
|
|
75
75
|
"client_name": "Cursor AI Agent",
|
|
@@ -102,7 +102,7 @@ This app implements a site that includes an MCP server with OAuth 2.1 authorizat
|
|
|
102
102
|
POST /oauth/token HTTP/1.1
|
|
103
103
|
Host: auth.example.com
|
|
104
104
|
Content-Type: application/x-www-form-urlencoded
|
|
105
|
-
|
|
105
|
+
|
|
106
106
|
grant_type=authorization_code&code=splat-auth-code-123&redirect_uri=http%3A%2F%2Flocalhost%3A8400%2Fcallback&client_id=mcp_client_xyz789&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
|
|
107
107
|
```
|
|
108
108
|
|
|
@@ -112,7 +112,7 @@ This app implements a site that includes an MCP server with OAuth 2.1 authorizat
|
|
|
112
112
|
```
|
|
113
113
|
HTTP/1.1 200 OK
|
|
114
114
|
Content-Type: application/json
|
|
115
|
-
|
|
115
|
+
|
|
116
116
|
{
|
|
117
117
|
"access_token": "mcp_access_token_abc123",
|
|
118
118
|
"token_type": "Bearer",
|
|
@@ -1,16 +1,26 @@
|
|
|
1
|
+
AuthStore = import './_lib/auth_store'
|
|
2
|
+
|
|
1
3
|
export ->(req) {
|
|
2
4
|
req.validate_http_method('post')
|
|
3
5
|
req.validate_content_type('application/json')
|
|
4
6
|
|
|
5
|
-
if valid_token?(req)
|
|
6
|
-
respond_authorized(req)
|
|
7
|
-
else
|
|
7
|
+
if !(token_info = valid_token?(req))
|
|
8
8
|
respond_unauthorized(req)
|
|
9
|
+
return
|
|
9
10
|
end
|
|
11
|
+
|
|
12
|
+
handle(req, token_info)
|
|
10
13
|
}
|
|
11
14
|
|
|
15
|
+
# @param req [Syntropy::Request]
|
|
12
16
|
def valid_token?(req)
|
|
13
|
-
|
|
17
|
+
token = req.auth_bearer_token
|
|
18
|
+
return false if !token
|
|
19
|
+
|
|
20
|
+
token_info = AuthStore.fetch(token)
|
|
21
|
+
return false if !token_info
|
|
22
|
+
|
|
23
|
+
true
|
|
14
24
|
end
|
|
15
25
|
|
|
16
26
|
def respond_unauthorized(req)
|
|
@@ -27,12 +37,49 @@ def respond_unauthorized(req)
|
|
|
27
37
|
)
|
|
28
38
|
end
|
|
29
39
|
|
|
30
|
-
def
|
|
40
|
+
def handle(req, token_info)
|
|
41
|
+
req.validate_http_method('post')
|
|
42
|
+
req.validate_content_type('application/json')
|
|
43
|
+
json = JSON.parse(req.read)
|
|
44
|
+
|
|
45
|
+
req.validate(json['jsonrpc'], '2.0')
|
|
46
|
+
req.validate(json['id'], [Integer, String])
|
|
47
|
+
|
|
48
|
+
method = req.validate(json['method'], String)
|
|
49
|
+
sym = :"handle_#{method}"
|
|
50
|
+
raise Syntropy::ValidationError, 'METHOD_NOT_FOUND: method not found' if !respond_to?(sym)
|
|
51
|
+
|
|
52
|
+
send(sym, req, json, token_info)
|
|
53
|
+
rescue Syntropy::ValidationError => e
|
|
54
|
+
if (m = e.message.match(/(.+)\: (.+)/))
|
|
55
|
+
type, message = m[1], m[2]
|
|
56
|
+
else
|
|
57
|
+
type, message = 'INVALID_REQUEST', e.message
|
|
58
|
+
end
|
|
59
|
+
respond_error(req, json, type, message)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
ERROR_CODES = {
|
|
63
|
+
'INVALID_REQUEST' => -32600,
|
|
64
|
+
'METHOD_NOT_FOUND' => -32601,
|
|
65
|
+
'INVALID_PARAMS' => -32602,
|
|
66
|
+
'INTERNAL_ERROR' => -32603,
|
|
67
|
+
'PARSE_ERROR' => -32700
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def respond_error(req, json, error_type, error_message)
|
|
71
|
+
error_code = ERROR_CODES[type] || ERROR_CODES['INTERNAL_ERROR']
|
|
31
72
|
req.respond_json(
|
|
32
73
|
{
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
74
|
+
jsonrpc: '2.0',
|
|
75
|
+
id: json['id'],
|
|
76
|
+
error: {
|
|
77
|
+
code: error_code,
|
|
78
|
+
message: error_message
|
|
79
|
+
}
|
|
36
80
|
}
|
|
37
81
|
)
|
|
38
82
|
end
|
|
83
|
+
|
|
84
|
+
def handle_initialize()
|
|
85
|
+
end
|
|
@@ -9,14 +9,6 @@ export ->(req) {
|
|
|
9
9
|
client_info = AuthStore.fetch(params['client_id'])
|
|
10
10
|
req.validate(client_info, Hash)
|
|
11
11
|
|
|
12
|
-
# GET /oauth/authorize?
|
|
13
|
-
# response_type=code
|
|
14
|
-
# client_id=mcp_client_xyz789
|
|
15
|
-
# redirect_uri=http%3A%2F%2Flocalhost%3A8400%2Fcallback
|
|
16
|
-
# code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
|
|
17
|
-
# code_challenge_method=S256
|
|
18
|
-
# state=random_state_string
|
|
19
|
-
|
|
20
12
|
key = AuthStore.store(req.query)
|
|
21
13
|
req.respond(nil, {
|
|
22
14
|
':status' => Syntropy::HTTP::FOUND,
|