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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syntropy
|
|
4
|
+
module DB
|
|
5
|
+
class Schema
|
|
6
|
+
def initialize(module_loader: nil, schema_root: '_schema', &)
|
|
7
|
+
@migrations = {}
|
|
8
|
+
@module_loader = module_loader
|
|
9
|
+
@schema_root = schema_root
|
|
10
|
+
load_schema_from_modules if @module_loader
|
|
11
|
+
run_schema_block(&) if block_given?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def apply(connection_pool)
|
|
15
|
+
execute_migrations(connection_pool)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def current_version(connection_pool)
|
|
19
|
+
connection_pool.with_db do |db|
|
|
20
|
+
get_schema_version(db)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def load_schema_from_modules
|
|
27
|
+
modules = @module_loader.list(@schema_root)
|
|
28
|
+
modules.each do |name|
|
|
29
|
+
@migrations[File.basename(name)] = @module_loader.load(name)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class SchemaBlockRunner
|
|
34
|
+
def initialize(migrations, &)
|
|
35
|
+
@migrations = migrations
|
|
36
|
+
instance_eval(&)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initial(&block)
|
|
40
|
+
@migrations['0000'] = block
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def version(key, &block)
|
|
44
|
+
@migrations[key] = block
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run_schema_block(&)
|
|
49
|
+
SchemaBlockRunner.new(@migrations, &)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def execute_migrations(connection_pool)
|
|
53
|
+
connection_pool.with_db do |db|
|
|
54
|
+
current_version = get_schema_version(db)
|
|
55
|
+
migrations_keys = @migrations.keys.sort
|
|
56
|
+
migrations_keys.select { it > current_version } if current_version
|
|
57
|
+
|
|
58
|
+
migrations_keys.each do |key|
|
|
59
|
+
db.transaction do
|
|
60
|
+
@migrations[key].(db)
|
|
61
|
+
set_schema_version(db, key)
|
|
62
|
+
current_version = key
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
current_version
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def get_schema_version(db)
|
|
71
|
+
db.execute <<~SQL
|
|
72
|
+
create table if not exists __syntropy_schema__(
|
|
73
|
+
k text primary key,
|
|
74
|
+
v text
|
|
75
|
+
);
|
|
76
|
+
SQL
|
|
77
|
+
db.query_single_splat <<~SQL
|
|
78
|
+
select v from __syntropy_schema__
|
|
79
|
+
where k = 'version'
|
|
80
|
+
SQL
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def set_schema_version(db, version)
|
|
84
|
+
db.execute <<~SQL, v: version
|
|
85
|
+
insert into __syntropy_schema__ (k, v)
|
|
86
|
+
values ('version', :v)
|
|
87
|
+
on conflict(k) do update set v = :v
|
|
88
|
+
SQL
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syntropy
|
|
4
|
+
module DB
|
|
5
|
+
class Store
|
|
6
|
+
def initialize(connection_pool)
|
|
7
|
+
@connection_pool = connection_pool
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def query(sql, *, **)
|
|
11
|
+
@connection_pool.with_db { it.query(sql, *, **) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def query_single_row(sql, *, **)
|
|
15
|
+
@connection_pool.with_db { it.query_single(sql, *, **) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def query_single_value(sql, *, **)
|
|
19
|
+
@connection_pool.with_db { it.query_single_splat(sql, *, **) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def execute(sql, *, **)
|
|
23
|
+
@connection_pool.with_db { it.execute(sql, *, **) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def transaction(&)
|
|
27
|
+
@connection_pool.with_db(&)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -5,13 +5,13 @@ require 'syntropy/errors'
|
|
|
5
5
|
module Syntropy
|
|
6
6
|
module HTTP
|
|
7
7
|
module ProtocolMethods
|
|
8
|
-
RE_REQUEST_LINE = /^(
|
|
8
|
+
RE_REQUEST_LINE = /^(get|head|options|trace|put|delete|post|patch|connect)\s+([^\s]+)\s+HTTP\/([019\.]{1,3})/i
|
|
9
9
|
RE_RESPONSE_LINE = /^HTTP\/1\.1\s+(\d{3})(\s+.+)?$/i
|
|
10
|
-
RE_HEADER_LINE = /^([a-z0-9
|
|
10
|
+
RE_HEADER_LINE = /^([a-z0-9\-]+):\s+(.+)/i
|
|
11
11
|
|
|
12
12
|
MAX_REQUEST_LINE_LEN = 1 << 14 # 16KB
|
|
13
13
|
MAX_RESPONSE_LINE_LEN = 1 << 8 # 256
|
|
14
|
-
MAX_HEADER_LINE_LEN = 1 <<
|
|
14
|
+
MAX_HEADER_LINE_LEN = 1 << 13 # 8KB
|
|
15
15
|
MAX_CHUNK_SIZE_LEN = 16
|
|
16
16
|
|
|
17
17
|
# @return [Hash] headers
|
|
@@ -27,8 +27,7 @@ module Syntropy
|
|
|
27
27
|
|
|
28
28
|
headers = {
|
|
29
29
|
':method' => m[1].downcase,
|
|
30
|
-
':path' => m[2]
|
|
31
|
-
':protocol' => 'http/1.1'
|
|
30
|
+
':path' => m[2]
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
loop do
|
|
@@ -93,6 +92,21 @@ module Syntropy
|
|
|
93
92
|
nil
|
|
94
93
|
end
|
|
95
94
|
|
|
95
|
+
def http_skip_body(headers)
|
|
96
|
+
content_length = headers['content-length']
|
|
97
|
+
if content_length
|
|
98
|
+
return skip(content_length.to_i)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
|
|
102
|
+
if chunked_encoding
|
|
103
|
+
while http_skip_cte_chunk
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
96
110
|
def http_read_body_chunk(headers)
|
|
97
111
|
content_length = headers['content-length']
|
|
98
112
|
if content_length
|
|
@@ -141,6 +155,20 @@ module Syntropy
|
|
|
141
155
|
|
|
142
156
|
buffer ? (buffer << chunk) : chunk
|
|
143
157
|
end
|
|
158
|
+
|
|
159
|
+
def http_skip_cte_chunk
|
|
160
|
+
chunk_size_str = read_line(MAX_CHUNK_SIZE_LEN)
|
|
161
|
+
return if !chunk_size_str
|
|
162
|
+
|
|
163
|
+
chunk_size = chunk_size_str.to_i(16)
|
|
164
|
+
if chunk_size == 0
|
|
165
|
+
read_line(0)
|
|
166
|
+
return nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
chunk = skip(chunk_size)
|
|
170
|
+
read_line(0)
|
|
171
|
+
end
|
|
144
172
|
end
|
|
145
173
|
end
|
|
146
174
|
end
|
|
@@ -23,12 +23,11 @@ module Syntropy
|
|
|
23
23
|
|
|
24
24
|
@done = nil
|
|
25
25
|
@response_headers = nil
|
|
26
|
+
@response_cookies = nil
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def run
|
|
29
30
|
loop do
|
|
30
|
-
@done = nil
|
|
31
|
-
@response_headers = nil
|
|
32
31
|
persist = serve_request
|
|
33
32
|
break if !persist
|
|
34
33
|
end
|
|
@@ -47,14 +46,21 @@ module Syntropy
|
|
|
47
46
|
# object and handing it off to the app handler. Returns true if the
|
|
48
47
|
# connection should be persisted.
|
|
49
48
|
def serve_request
|
|
49
|
+
@done = nil
|
|
50
|
+
@response_headers = nil
|
|
51
|
+
@response_cookies = nil
|
|
50
52
|
@closed = nil
|
|
51
|
-
headers =
|
|
53
|
+
headers = @io.http_read_request_headers
|
|
52
54
|
return false if !headers
|
|
53
55
|
|
|
54
56
|
request = Syntropy::Request.new(headers, self)
|
|
55
57
|
|
|
56
58
|
@app.call(request)
|
|
57
|
-
persist_connection?(headers)
|
|
59
|
+
persist = persist_connection?(headers)
|
|
60
|
+
if persist && !headers[':body-done-reading'] && (headers['content-length'] || headers['transfer-encoding'])
|
|
61
|
+
get_body(request)
|
|
62
|
+
end
|
|
63
|
+
persist
|
|
58
64
|
rescue StandardError => e
|
|
59
65
|
handle_error(request, e)
|
|
60
66
|
false
|
|
@@ -126,13 +132,10 @@ module Syntropy
|
|
|
126
132
|
@response_headers ? @response_headers.merge!(headers) : @response_headers = headers
|
|
127
133
|
end
|
|
128
134
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
else
|
|
134
|
-
set_response_headers('Set-Cookie' => cookies)
|
|
135
|
-
end
|
|
135
|
+
DELETE_COOKIE = "; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; Max-Age=0; HttpOnly"
|
|
136
|
+
|
|
137
|
+
def set_cookie(key, value)
|
|
138
|
+
(@response_cookies ||= {})[key] = value || DELETE_COOKIE
|
|
136
139
|
end
|
|
137
140
|
|
|
138
141
|
SEND_FLAGS = UM::MSG_NOSIGNAL | UM::MSG_WAITALL
|
|
@@ -148,6 +151,7 @@ module Syntropy
|
|
|
148
151
|
# @param body [String] response body
|
|
149
152
|
# @param headers
|
|
150
153
|
def respond(request, body, headers)
|
|
154
|
+
add_set_cookie_headers if @response_cookies
|
|
151
155
|
headers = @response_headers.merge(headers) if @response_headers
|
|
152
156
|
|
|
153
157
|
formatted_headers = format_headers(headers, body)
|
|
@@ -264,57 +268,6 @@ module Syntropy
|
|
|
264
268
|
return connection != 'close'
|
|
265
269
|
end
|
|
266
270
|
|
|
267
|
-
def parse_headers
|
|
268
|
-
headers = get_request_line(MAX_REQUEST_LINE_LEN)
|
|
269
|
-
return nil if !headers
|
|
270
|
-
|
|
271
|
-
loop do
|
|
272
|
-
line = @io.read_line(MAX_HEADER_LINE_LEN)
|
|
273
|
-
break if line.nil? || line.empty?
|
|
274
|
-
|
|
275
|
-
m = line.match(RE_HEADER_LINE)
|
|
276
|
-
raise ProtocolError, "Invalid header: #{line[0..2047].inspect}" if !m
|
|
277
|
-
|
|
278
|
-
headers[m[1].downcase] = m[2]
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
headers
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
def get_request_line(buf)
|
|
285
|
-
line = @io.read_line(MAX_REQUEST_LINE_LEN)
|
|
286
|
-
return nil if !line
|
|
287
|
-
|
|
288
|
-
m = line.match(RE_REQUEST_LINE)
|
|
289
|
-
raise ProtocolError, 'Invalid request line' if !m
|
|
290
|
-
|
|
291
|
-
http_version = m[3]
|
|
292
|
-
raise UnsupportedHTTPVersionError, 'HTTP version not supported' if http_version != '1.1'
|
|
293
|
-
|
|
294
|
-
{
|
|
295
|
-
':method' => m[1].downcase,
|
|
296
|
-
':path' => m[2],
|
|
297
|
-
':protocol' => 'http/1.1'
|
|
298
|
-
}
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
def read_chunk(headers, buffer)
|
|
302
|
-
chunk_size_str = @io.read_line(MAX_CHUNK_SIZE_LEN)
|
|
303
|
-
return nil if !chunk_size_str
|
|
304
|
-
|
|
305
|
-
chunk_size = chunk_size_str.to_i(16)
|
|
306
|
-
if chunk_size == 0
|
|
307
|
-
headers[':body-done-reading'] = true
|
|
308
|
-
@io.read_line(0)
|
|
309
|
-
return nil
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
chunk = @io.read(chunk_size)
|
|
313
|
-
@io.read_line(0)
|
|
314
|
-
|
|
315
|
-
buffer ? (buffer << chunk) : chunk
|
|
316
|
-
end
|
|
317
|
-
|
|
318
271
|
INTERNAL_HEADER_REGEXP = /^:/
|
|
319
272
|
|
|
320
273
|
# Formats response headers into an array. If empty_response is true(thy),
|
|
@@ -362,6 +315,12 @@ module Syntropy
|
|
|
362
315
|
lines << "#{key}: #{value}\r\n"
|
|
363
316
|
end
|
|
364
317
|
end
|
|
318
|
+
|
|
319
|
+
def add_set_cookie_headers
|
|
320
|
+
@response_headers ||= {}
|
|
321
|
+
sc = (@response_headers['Set-Cookie'] ||= [])
|
|
322
|
+
@response_cookies.each { |k, v| sc << "#{k}=#{v}" }
|
|
323
|
+
end
|
|
365
324
|
end
|
|
366
325
|
end
|
|
367
326
|
end
|
|
@@ -27,8 +27,8 @@ module Syntropy
|
|
|
27
27
|
# @param env [Hash] environment hash
|
|
28
28
|
# @return [void]
|
|
29
29
|
def initialize(env)
|
|
30
|
-
@root_dir = env[:root_dir]
|
|
31
30
|
@env = env
|
|
31
|
+
@root_dir = env[:root_dir]
|
|
32
32
|
@modules = {} # maps ref to module entry
|
|
33
33
|
@fn_map = {} # maps filename to ref
|
|
34
34
|
end
|
|
@@ -37,13 +37,28 @@ module Syntropy
|
|
|
37
37
|
#
|
|
38
38
|
# @param ref [String] module reference
|
|
39
39
|
# @return [any] export value
|
|
40
|
-
def load(ref)
|
|
40
|
+
def load(ref, raise_on_missing: true)
|
|
41
41
|
ref = "/#{ref}" if ref !~ /^\//
|
|
42
|
+
if !(entry = @modules[ref])
|
|
43
|
+
entry = load_module(ref, raise_on_missing:)
|
|
44
|
+
return if !entry
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
@modules[ref] = entry
|
|
47
|
+
end
|
|
44
48
|
entry[:export_value]
|
|
45
49
|
end
|
|
46
50
|
|
|
51
|
+
# Returns a list of modules found in the given relative path. The module
|
|
52
|
+
# references are returned as absolute paths (relative to the module loader
|
|
53
|
+
# root directory).
|
|
54
|
+
#
|
|
55
|
+
# @param dir [String] relative module directory
|
|
56
|
+
# @return [Array<String>] list of modules
|
|
57
|
+
def list(dir)
|
|
58
|
+
fns = Dir[File.join(@root_dir, dir, '*.rb')]
|
|
59
|
+
fns.map { it.match(/^#{@root_dir}\/(.+)\.rb$/)[1] }.sort
|
|
60
|
+
end
|
|
61
|
+
|
|
47
62
|
# Invalidates a module by its filename, normally following a change to the
|
|
48
63
|
# underlying file (in order to cause reloading of the module). The module
|
|
49
64
|
# will be removed from the modules map, as well as modules dependending on
|
|
@@ -101,17 +116,23 @@ module Syntropy
|
|
|
101
116
|
#
|
|
102
117
|
# @param ref [String] module reference
|
|
103
118
|
# @return [Hash] module entry
|
|
104
|
-
def load_module(ref)
|
|
119
|
+
def load_module(ref, raise_on_missing: true)
|
|
105
120
|
ref = "/#{ref}" if ref !~ /^\//
|
|
106
121
|
fn = File.expand_path(File.join(@root_dir, "#{ref}.rb"))
|
|
107
|
-
|
|
122
|
+
if !File.file?(fn)
|
|
123
|
+
raise Syntropy::Error, "File not found #{fn}" if raise_on_missing
|
|
124
|
+
|
|
125
|
+
return
|
|
126
|
+
end
|
|
108
127
|
|
|
109
128
|
@fn_map[fn] = ref
|
|
110
129
|
code = IO.read(fn)
|
|
111
130
|
env = @env.merge(module_loader: self, ref: clean_ref(ref))
|
|
112
131
|
mod = Syntropy::Module.load(env, code, fn)
|
|
113
132
|
add_dependencies(ref, mod.__dependencies__)
|
|
114
|
-
export_value = transform_module_export_value(
|
|
133
|
+
export_value = transform_module_export_value(
|
|
134
|
+
mod.__export_value__, fn, raise_on_missing:
|
|
135
|
+
)
|
|
115
136
|
|
|
116
137
|
{
|
|
117
138
|
fn: fn,
|
|
@@ -133,10 +154,10 @@ module Syntropy
|
|
|
133
154
|
#
|
|
134
155
|
# @param export_value [any] module's export value
|
|
135
156
|
# @return [any] transformed value
|
|
136
|
-
def transform_module_export_value(export_value)
|
|
157
|
+
def transform_module_export_value(export_value, fn, raise_on_missing:)
|
|
137
158
|
case export_value
|
|
138
159
|
when nil
|
|
139
|
-
raise Syntropy::Error,
|
|
160
|
+
raise Syntropy::Error, "No export found in #{fn}" if raise_on_missing
|
|
140
161
|
when String
|
|
141
162
|
->(req) { req.respond(export_value) }
|
|
142
163
|
when Class
|
|
@@ -278,9 +299,28 @@ module Syntropy
|
|
|
278
299
|
# environment is based on the module's env merged with the given parameters.
|
|
279
300
|
#
|
|
280
301
|
# @param env [Hash] environment
|
|
302
|
+
# @return [Syntropy::App]
|
|
281
303
|
def app(**env)
|
|
282
304
|
env = @env.merge(env)
|
|
283
305
|
Syntropy::App.new(**env)
|
|
284
306
|
end
|
|
307
|
+
|
|
308
|
+
# Returns a request handler that handles requests by calling the appropriate
|
|
309
|
+
# module method (e.g. get, post, etc.)
|
|
310
|
+
#
|
|
311
|
+
# @return [Proc]
|
|
312
|
+
def http_methods
|
|
313
|
+
->(req) { route_by_http_method(req) }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Handles the given request by calling the module method corresponding to
|
|
317
|
+
# the request's HTTP method. If no method is found, raises a
|
|
318
|
+
# method_not_allowed error.
|
|
319
|
+
def route_by_http_method(req)
|
|
320
|
+
sym = req.method.to_sym
|
|
321
|
+
raise Syntropy::Error.method_not_allowed if !respond_to?(sym)
|
|
322
|
+
|
|
323
|
+
send(sym, req)
|
|
324
|
+
end
|
|
285
325
|
end
|
|
286
326
|
end
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'uri'
|
|
4
|
-
require 'escape_utils'
|
|
5
4
|
|
|
6
5
|
module Syntropy
|
|
7
6
|
module RequestInfoMethods
|
|
@@ -122,7 +121,7 @@ module Syntropy
|
|
|
122
121
|
raise BadRequestError, 'Invalid cookie format' unless c.strip =~ COOKIE_RE
|
|
123
122
|
|
|
124
123
|
key, value = Regexp.last_match[1..2]
|
|
125
|
-
h[key] =
|
|
124
|
+
h[key] = URI.decode_www_form_component(value)
|
|
126
125
|
end
|
|
127
126
|
end
|
|
128
127
|
|
|
@@ -159,7 +158,7 @@ module Syntropy
|
|
|
159
158
|
|
|
160
159
|
def auth_bearer_token
|
|
161
160
|
auth = headers['authorization']
|
|
162
|
-
if (m = auth.match(/Bearer\s+([^\w]+)/))
|
|
161
|
+
if auth && (m = auth.match(/Bearer\s+([^\w]+)/))
|
|
163
162
|
return m[1]
|
|
164
163
|
end
|
|
165
164
|
|
|
@@ -248,7 +247,7 @@ module Syntropy
|
|
|
248
247
|
v = Regexp.last_match(2)
|
|
249
248
|
raise BadRequestError, 'Invalid parameter size' if v && v.size > MAX_PARAMETER_VALUE_SIZE
|
|
250
249
|
|
|
251
|
-
m[
|
|
250
|
+
m[URI.decode_www_form_component(k)] = v ? URI.decode_www_form_component(v) : true
|
|
252
251
|
end
|
|
253
252
|
end
|
|
254
253
|
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module Syntropy
|
|
8
|
+
class Session
|
|
9
|
+
def initialize(request)
|
|
10
|
+
@request = request
|
|
11
|
+
@data = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def [](key)
|
|
15
|
+
@data ||= load
|
|
16
|
+
@data[key]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def []=(key, value)
|
|
20
|
+
@data ||= load
|
|
21
|
+
@data[key] = value
|
|
22
|
+
save(@data)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delete(key)
|
|
26
|
+
@data ||= load
|
|
27
|
+
@data.delete(key)
|
|
28
|
+
save(@data.empty? ? nil : @data)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def discard
|
|
32
|
+
save(nil)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def flash
|
|
36
|
+
@data ||= load
|
|
37
|
+
@flash ||= Flash.new(self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
class NowFlash
|
|
43
|
+
def initialize
|
|
44
|
+
@data = {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def [](key)
|
|
48
|
+
@data[key.to_s]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def []=(key, value)
|
|
52
|
+
@data[key.to_s] = value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def each(&block)
|
|
56
|
+
@data.each { |k, v| block.(k.to_sym, v) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class Flash
|
|
61
|
+
def initialize(session)
|
|
62
|
+
@session = session
|
|
63
|
+
@current_flash_data = @session['_flash']
|
|
64
|
+
@session.delete('_flash') if @current_flash_data
|
|
65
|
+
@current_flash_data ||= {}
|
|
66
|
+
@future_flash_data = {}
|
|
67
|
+
@now_flash_data = NowFlash.new
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def [](key)
|
|
71
|
+
key = key.to_s
|
|
72
|
+
@now_flash_data[key] || @current_flash_data[key]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def []=(key, value)
|
|
76
|
+
key = key.to_s
|
|
77
|
+
@future_flash_data[key] = value
|
|
78
|
+
@session['_flash'] = @future_flash_data
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def each(&block)
|
|
82
|
+
@now_flash_data.each { |k, v| block.(k.to_sym, v) }
|
|
83
|
+
@current_flash_data.each_pair { |k, v| block.(k.to_sym, v) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def keep
|
|
87
|
+
@future_flash_data = @current_flash_data.merge!(@future_flash_data)
|
|
88
|
+
@session['_flash'] = @future_flash_data
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def now
|
|
92
|
+
@now_flash_data
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Loads session data from
|
|
97
|
+
def load
|
|
98
|
+
data = @request.cookies['__syntropy_session__']
|
|
99
|
+
return {} if !data
|
|
100
|
+
|
|
101
|
+
JSON.parse(Base64.decode64(data))
|
|
102
|
+
rescue JSON::ParserError
|
|
103
|
+
{}
|
|
104
|
+
ensure
|
|
105
|
+
@loaded = true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def save(data)
|
|
109
|
+
cookie = data ? "#{Base64.strict_encode64(JSON.dump(data))}; Path=/; HttpOnly" : nil
|
|
110
|
+
@request.set_cookie('__syntropy_session__', cookie)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'uri'
|
|
4
|
-
require 'escape_utils'
|
|
5
4
|
|
|
6
5
|
module Syntropy
|
|
7
6
|
module RequestValidationMethods
|
|
@@ -40,7 +39,7 @@ module Syntropy
|
|
|
40
39
|
# @clauses [Array] one or more validation clauses
|
|
41
40
|
# @return [any] validated parameter value
|
|
42
41
|
def validate_param(name, *clauses)
|
|
43
|
-
validate(query[name], *clauses)
|
|
42
|
+
validate(query[name.to_s], *clauses)
|
|
44
43
|
end
|
|
45
44
|
|
|
46
45
|
# Validates and optionally converts a value against the given clauses. If no
|
data/lib/syntropy/request.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative './request/request_info'
|
|
4
4
|
require_relative './request/validation'
|
|
5
5
|
require_relative './request/response'
|
|
6
|
+
require_relative './request/session'
|
|
6
7
|
require_relative './http/status'
|
|
7
8
|
|
|
8
9
|
module Syntropy
|
|
@@ -95,5 +96,13 @@ module Syntropy
|
|
|
95
96
|
def total_transfer
|
|
96
97
|
(headers[':rx'] || 0) + (headers[':tx'] || 0)
|
|
97
98
|
end
|
|
99
|
+
|
|
100
|
+
def session
|
|
101
|
+
@session ||= Session.new(self)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def flash
|
|
105
|
+
session.flash
|
|
106
|
+
end
|
|
98
107
|
end
|
|
99
108
|
end
|