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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +20 -0
  4. data/TODO.md +7 -1
  5. data/cmd/console.rb +77 -0
  6. data/cmd/serve.rb +1 -3
  7. data/cmd/test.rb +76 -20
  8. data/examples/blog/app/_layout/default.rb +11 -0
  9. data/examples/blog/app/_lib/post_store.rb +47 -0
  10. data/examples/blog/app/_schema/2026-01-01-initial.rb +9 -0
  11. data/examples/blog/app/_setup.rb +4 -0
  12. data/examples/blog/app/index.rb +7 -0
  13. data/examples/blog/app/posts/[id]/edit.rb +33 -0
  14. data/examples/blog/app/posts/[id]/index.rb +61 -0
  15. data/examples/blog/app/posts/index.rb +40 -0
  16. data/examples/blog/app/posts/new.rb +29 -0
  17. data/examples/mcp-oauth/README.md +3 -3
  18. data/examples/mcp-oauth/app/mcp.rb +55 -8
  19. data/examples/mcp-oauth/app/oauth/authorize.rb +0 -8
  20. data/examples/mcp-oauth/app/oauth/register.rb +0 -1
  21. data/examples/mcp-oauth/test/test_app.rb +2 -20
  22. data/examples/mcp-oauth/test/test_oauth.rb +93 -217
  23. data/lib/syntropy/app.rb +23 -9
  24. data/lib/syntropy/db/connection_pool.rb +71 -0
  25. data/lib/syntropy/db/schema.rb +92 -0
  26. data/lib/syntropy/db/store.rb +31 -0
  27. data/lib/syntropy/http/io_extensions.rb +33 -5
  28. data/lib/syntropy/http/server_connection.rb +21 -62
  29. data/lib/syntropy/{module.rb → module_loader.rb} +48 -8
  30. data/lib/syntropy/request/request_info.rb +3 -4
  31. data/lib/syntropy/request/response.rb +2 -2
  32. data/lib/syntropy/request/session.rb +113 -0
  33. data/lib/syntropy/request/validation.rb +1 -2
  34. data/lib/syntropy/request.rb +9 -0
  35. data/lib/syntropy/test.rb +84 -1
  36. data/lib/syntropy/version.rb +1 -1
  37. data/lib/syntropy.rb +4 -2
  38. data/syntropy.gemspec +3 -1
  39. data/test/app/_hook.rb +1 -1
  40. data/test/app/by_method.rb +9 -0
  41. data/test/app_setup/_setup.rb +7 -0
  42. data/test/app_setup/index.rb +1 -0
  43. data/test/app_with_schema/_schema/2026-01-02-foo.rb +12 -0
  44. data/test/app_with_schema/_schema/2026-05-30-bar.rb +7 -0
  45. data/test/schema/2026-01-02-foo.rb +12 -0
  46. data/test/schema/2026-05-30-bar.rb +7 -0
  47. data/test/test_app.rb +58 -3
  48. data/test/{test_connection_pool.rb → test_db_connection_pool.rb} +7 -2
  49. data/test/test_db_schema.rb +96 -0
  50. data/test/test_db_store.rb +24 -0
  51. data/test/test_http_protocol.rb +250 -0
  52. data/test/test_http_server_connection.rb +18 -24
  53. data/test/test_json_api.rb +1 -1
  54. data/test/{test_module.rb → test_module_loader.rb} +31 -0
  55. data/test/test_request.rb +7 -4
  56. data/test/test_request_session.rb +254 -0
  57. data/test/test_server.rb +9 -13
  58. metadata +63 -12
  59. data/examples/mcp-oauth/test/helper.rb +0 -9
  60. 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 = /^([a-z]+)\s+([^\s]+)\s+HTTP\/([019\.]{1,3})/i
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-]+):\s+(.+)/i
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 << 10 # 1KB
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 = parse_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
- def set_cookie(*cookies)
130
- existing_cookies = @response_headers && @response_headers['Set-Cookie']
131
- if existing_cookies
132
- @response_headers['Set-Cookie'] = existing_cookies + cookies
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
- entry = (@modules[ref] ||= load_module(ref))
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
- raise Syntropy::Error, "File not found #{fn}" if !File.file?(fn)
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(mod.__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, 'No export found'
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] = EscapeUtils.unescape_uri(value)
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[EscapeUtils.unescape_uri(k)] = v ? EscapeUtils.unescape_uri(v) : true
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
@@ -105,8 +105,8 @@ module Syntropy
105
105
  adapter.set_response_headers(headers)
106
106
  end
107
107
 
108
- def set_cookie(*)
109
- adapter.set_cookie(*)
108
+ def set_cookie(k, v)
109
+ adapter.set_cookie(k, v)
110
110
  end
111
111
 
112
112
  def upgrade(protocol, custom_headers = nil, &block)
@@ -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
@@ -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